import OverType, { type OverType as OverTypeInstance } from "overtype"; import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { ReactNode, ChangeEvent } from "react"; import { useComponentsContext } from "@blocknote/react"; import { EditLinkMenuItems } from "@blocknote/react"; import { isStepAutocompleteOnTypeEnabled, useStepAutocomplete, type StepSuggestion } from "../stepAutocomplete"; import { type SnippetSuggestion } from "../snippetAutocomplete"; import { useStepImageUpload } from "../stepImageUpload"; import { escapeMarkdownText, normalizePlainText } from "./markdown"; import { backtickRunLength, codeBlockFence, findBacktickRun, scanCodeSpan, wrapCodeSpan } from "../codeSpan"; import { useAutoResize } from "./useAutoResize"; type Suggestion = StepSuggestion | SnippetSuggestion; type StepFieldProps = { label: string; showLabel?: boolean; labelToggle?: { onClick: () => void; expanded: boolean; }; labelAction?: ReactNode; placeholder?: string; value: string; onChange: (nextValue: string) => void; autoFocus?: boolean; focusSignal?: number; multiline?: boolean; disableNewlines?: boolean; enableAutocomplete?: boolean; fieldName?: string; suggestionFilter?: (suggestion: Suggestion) => boolean; suggestionsOverride?: Suggestion[]; onSuggestionSelect?: (suggestion: Suggestion) => void; readOnly?: boolean; showSuggestionsOnFocus?: boolean; enableImageUpload?: boolean; onImageFile?: (file: File) => Promise | void; rightAction?: ReactNode; showFormattingButtons?: boolean; showImageButton?: boolean; onFieldFocus?: () => 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([ "ArrowDown", "ArrowUp", "Enter", "Tab", ]); const AUTOCOMPLETE_TRIGGER_KEYS = new Set([" ", "Space"]); const markdownParser = ( OverType as { MarkdownParser?: { parse: ( markdown: string, activeLine?: number, showActiveLineRaw?: boolean, instanceHighlighter?: unknown, isPreviewMode?: boolean, ) => string; }; } ).MarkdownParser; /** * `useLayoutEffect` that degrades to `useEffect` outside the browser so SSR * doesn't warn. Static previews and the OverType mount run pre-paint to avoid a * blank/jumping frame on the click→edit swap. */ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; function ImageUploadIcon() { return ( ); } type ExtractedImage = { id: string; url: string; alt: string; start: number; end: number; markdown: string; }; export type LinkMeta = { start: number; end: number; url: string }; export type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" }; type FormatType = "bold" | "italic" | "code"; /** * Remove formatting and link entries that conflict with applying `fmtType` * over the half-open range [start, end). Exclusion rules: * - code: exclusive with everything — drops any overlapping formatting and links * - bold/italic: coexist with each other, but exclusive with code and links * — drops overlapping same-type, overlapping code, and overlapping links */ export function applyInlineExclusion( formatting: FormattingMeta[], links: LinkMeta[], start: number, end: number, fmtType: FormatType, ): { formatting: FormattingMeta[]; links: LinkMeta[] } { const overlaps = (a: { start: number; end: number }) => !(a.start >= end || a.end <= start); const nextFormatting = formatting.filter((f) => { if (!overlaps(f)) return true; if (fmtType === "code") return false; if (f.type === "code") return false; return f.type !== fmtType; }); const nextLinks = links.filter((l) => !overlaps(l)); return { formatting: nextFormatting, links: nextLinks }; } type EditorSnapshot = { text: string; formatting: FormattingMeta[]; links: LinkMeta[]; cursorStart: number; cursorEnd: number; }; const UNDO_STACK_LIMIT = 100; function getActiveFormats( formatting: FormattingMeta[], selStart: number, selEnd: number, ): Set { const active = new Set(); if (selStart === selEnd) return active; for (const f of formatting) { if (selStart < f.end && selEnd > f.start) { active.add(f.type); } } return active; } export function stripInlineMarkdown(markdown: string): { plainText: string; links: LinkMeta[]; formatting: FormattingMeta[]; } { const links: LinkMeta[] = []; const formatting: FormattingMeta[] = []; let plainText = ""; let i = 0; while (i < markdown.length) { // Skip image syntax ![alt](url) — keep as-is if (markdown[i] === "!" && markdown[i + 1] === "[") { const endBracket = markdown.indexOf("]", i + 2); if (endBracket !== -1 && markdown[endBracket + 1] === "(") { const endParen = markdown.indexOf(")", endBracket + 2); if (endParen !== -1) { plainText += markdown.slice(i, endParen + 1); i = endParen + 1; continue; } } } // Links: [text](url) if (markdown[i] === "[") { const endBracket = markdown.indexOf("]", i + 1); if (endBracket !== -1 && markdown[endBracket + 1] === "(") { const endParen = markdown.indexOf(")", endBracket + 2); if (endParen !== -1) { const text = markdown.slice(i + 1, endBracket); const url = markdown.slice(endBracket + 2, endParen); links.push({ start: plainText.length, end: plainText.length + text.length, url }); plainText += text; i = endParen + 1; continue; } } } // Bold+Italic: *** or ___ if ( (markdown[i] === "*" && markdown[i + 1] === "*" && markdown[i + 2] === "*") || (markdown[i] === "_" && markdown[i + 1] === "_" && markdown[i + 2] === "_") ) { const marker = markdown.slice(i, i + 3); const closeIdx = markdown.indexOf(marker, i + 3); if (closeIdx !== -1) { const inner = markdown.slice(i + 3, closeIdx); const start = plainText.length; const innerResult = stripInlineMarkdown(inner); plainText += innerResult.plainText; for (const link of innerResult.links) { links.push({ start: start + link.start, end: start + link.end, url: link.url }); } for (const fmt of innerResult.formatting) { formatting.push({ start: start + fmt.start, end: start + fmt.end, type: fmt.type }); } formatting.push({ start, end: plainText.length, type: "bold" }); formatting.push({ start, end: plainText.length, type: "italic" }); i = closeIdx + 3; continue; } } // Bold: ** or __ if ( markdown[i] === "*" && markdown[i + 1] === "*" && markdown[i + 2] !== "*" || markdown[i] === "_" && markdown[i + 1] === "_" && markdown[i + 2] !== "_" ) { const marker = markdown.slice(i, i + 2); // Find closing ** that isn't part of *** let closeIdx = markdown.indexOf(marker, i + 2); while (closeIdx !== -1 && markdown[closeIdx + 2] === marker[0]) { closeIdx = markdown.indexOf(marker, closeIdx + 2); } if (closeIdx !== -1) { const inner = markdown.slice(i + 2, closeIdx); const start = plainText.length; const innerResult = stripInlineMarkdown(inner); plainText += innerResult.plainText; for (const link of innerResult.links) { links.push({ start: start + link.start, end: start + link.end, url: link.url }); } for (const fmt of innerResult.formatting) { formatting.push({ start: start + fmt.start, end: start + fmt.end, type: fmt.type }); } formatting.push({ start, end: plainText.length, type: "bold" }); i = closeIdx + 2; continue; } } // Italic: single * or _ if ( (markdown[i] === "*" && markdown[i + 1] !== "*") || (markdown[i] === "_" && markdown[i + 1] !== "_") ) { const marker = markdown[i]; // Find closing marker that isn't doubled let closeIdx = i + 1; while (closeIdx < markdown.length) { closeIdx = markdown.indexOf(marker, closeIdx); if (closeIdx === -1) break; if (markdown[closeIdx + 1] !== marker && markdown[closeIdx - 1] !== marker) break; closeIdx++; } if (closeIdx !== -1 && closeIdx > i + 1) { const inner = markdown.slice(i + 1, closeIdx); const start = plainText.length; const innerResult = stripInlineMarkdown(inner); plainText += innerResult.plainText; for (const link of innerResult.links) { links.push({ start: start + link.start, end: start + link.end, url: link.url }); } for (const fmt of innerResult.formatting) { formatting.push({ start: start + fmt.start, end: start + fmt.end, type: fmt.type }); } formatting.push({ start, end: plainText.length, type: "italic" }); i = closeIdx + 1; continue; } } if (markdown[i] === "`") { const run = backtickRunLength(markdown, i); // Code block: ```\n...\n``` — a fence of 3+ backticks closed by a run of // the same length with a line break in between. Kept multi-line, unlike // a code span whose line breaks collapse to spaces. if (run >= 3) { const contentStart = markdown[i + run] === "\n" ? i + run + 1 : i + run; const closeIdx = findBacktickRun(markdown, contentStart, run); if (closeIdx !== -1 && markdown.slice(i + run, closeIdx).includes("\n")) { const contentEnd = markdown[closeIdx - 1] === "\n" ? closeIdx - 1 : closeIdx; const start = plainText.length; plainText += markdown.slice(contentStart, Math.max(contentStart, contentEnd)); formatting.push({ start, end: plainText.length, type: "code" }); i = closeIdx + run; continue; } } // Inline code: `text`, ``te`xt``, ```text``` — CommonMark code span. // An unmatched run stays literal text. const scan = scanCodeSpan(markdown, i)!; if (scan.kind === "span") { const start = plainText.length; plainText += scan.content; formatting.push({ start, end: plainText.length, type: "code" }); } else { plainText += markdown.slice(i, scan.end); } i = scan.end; continue; } plainText += markdown[i]; i++; } return { plainText, links, formatting }; } export function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string { if (links.length === 0 && formatting.length === 0) return plainText; // Collect all marker insertions at each position in plainText space. // Each entry: { pos, text, order } where order controls insertion sequence // at the same position (lower order = inserted first = ends up leftmost). type Marker = { pos: number; text: string; order: number }; const markers: Marker[] = []; for (const fmt of formatting) { let openMarker: string; let closeMarker: string; if (fmt.type === "code") { const content = plainText.slice(fmt.start, fmt.end); if (content.includes("\n")) { const fence = codeBlockFence(content); openMarker = `${fence}\n`; closeMarker = `\n${fence}`; } else { // A delimiter longer than any backtick run inside, padded when the // content touches a backtick — split back into open/close halves. const wrapped = wrapCodeSpan(content); const delimiterLength = (wrapped.length - content.length) / 2; openMarker = wrapped.slice(0, delimiterLength); closeMarker = wrapped.slice(wrapped.length - delimiterLength); } } else { openMarker = fmt.type === "bold" ? "**" : "_"; closeMarker = openMarker; } // Opening: outer markers (bold) before inner (italic) → bold order=0, italic order=1 // Closing: inner markers (italic) before outer (bold) → italic order=0, bold order=1 const openOrder = fmt.type === "bold" ? 0 : fmt.type === "code" ? 2 : 1; const closeOrder = fmt.type === "bold" ? 1 : fmt.type === "code" ? -1 : 0; markers.push({ pos: fmt.start, text: openMarker, order: openOrder }); markers.push({ pos: fmt.end, text: closeMarker, order: closeOrder }); } for (const link of links) { // Link brackets go outside formatting markers markers.push({ pos: link.start, text: "[", order: -1 }); markers.push({ pos: link.end, text: `](${link.url})`, order: 2 }); } // Sort by position descending so we insert from end to start (preserving earlier positions). // At the same position, sort by order ascending. markers.sort((a, b) => b.pos - a.pos || a.order - b.order); let result = plainText; for (const m of markers) { result = result.slice(0, m.pos) + m.text + result.slice(m.pos); } return result; } function adjustFormattingForEdit(formatting: FormattingMeta[], editPos: number, delta: number): FormattingMeta[] { return formatting .map((fmt) => { if (delta >= 0) { if (editPos <= fmt.start) { return { ...fmt, start: fmt.start + delta, end: fmt.end + delta }; } if (editPos >= fmt.end) { return fmt; } return { ...fmt, end: fmt.end + delta }; } const delEnd = editPos + Math.abs(delta); const newStart = fmt.start < editPos ? fmt.start : fmt.start >= delEnd ? fmt.start + delta : editPos; const newEnd = fmt.end <= editPos ? fmt.end : fmt.end >= delEnd ? fmt.end + delta : editPos; return { ...fmt, start: newStart, end: newEnd }; }) .filter((fmt) => fmt.end > fmt.start); } function getCaretRectInPreview(preview: HTMLElement, offset: number, textareaValue?: string): { top: number; left: number; height: number } | null { // Convert textarea-space offset to preview-space (strip newlines) let nlCount = 0; if (textareaValue) { for (let i = 0; i < offset && i < textareaValue.length; i++) { if (textareaValue[i] === "\n") nlCount++; } } const previewOffset = offset - nlCount; const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT); let currentOffset = 0; while (walker.nextNode()) { const textNode = walker.currentNode as Text; const nodeLen = textNode.length; if (previewOffset <= currentOffset + nodeLen) { const localOffset = previewOffset - currentOffset; try { const range = document.createRange(); range.setStart(textNode, localOffset); range.collapse(true); let rect = range.getBoundingClientRect(); // Collapsed ranges at position 0 can return an empty rect in some browsers if (rect.height === 0 && rect.top === 0 && rect.left === 0) { const span = document.createElement("span"); span.textContent = "\u200B"; range.insertNode(span); rect = span.getBoundingClientRect(); span.parentNode?.removeChild(span); preview.normalize(); } const previewRect = preview.getBoundingClientRect(); return { top: rect.top - previewRect.top + preview.scrollTop, left: rect.left - previewRect.left + preview.scrollLeft, height: rect.height || parseFloat(getComputedStyle(preview).lineHeight) || 20, }; } catch { return null; } } currentOffset += nodeLen; } return null; } function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingMeta[], textareaValue?: string) { // Remove previous formatting highlights const existingBold = preview.querySelectorAll("strong.step-preview-bold"); for (let i = 0; i < existingBold.length; i++) { const el = existingBold[i]; const parent = el.parentNode; if (parent) { while (el.firstChild) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } } const existingItalic = preview.querySelectorAll("em.step-preview-italic"); for (let i = 0; i < existingItalic.length; i++) { const el = existingItalic[i]; const parent = el.parentNode; if (parent) { while (el.firstChild) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } } const existingCode = preview.querySelectorAll("code.step-preview-code"); for (let i = 0; i < existingCode.length; i++) { const el = existingCode[i]; const parent = el.parentNode; if (parent) { while (el.firstChild) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } } // After unwrapping formatting elements, merge adjacent/empty text nodes // so the tree walker sees clean text nodes matching the original structure. preview.normalize(); if (formatting.length === 0) return; // OverType splits textarea lines into
elements, discarding the \n // characters. Convert textarea-space positions (with \n) to preview-space // positions (without \n) so we can find the correct text nodes. function taToPreview(taPos: number): number { if (!textareaValue) return taPos; let nlCount = 0; for (let i = 0; i < taPos && i < textareaValue.length; i++) { if (textareaValue[i] === "\n") nlCount++; } return taPos - nlCount; } const sorted = [...formatting].sort((a, b) => b.start - a.start); for (const fmt of sorted) { const pStart = taToPreview(fmt.start); const pEnd = taToPreview(fmt.end); // Collect text nodes with their preview-space offsets const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT); let currentOffset = 0; let startNode: Text | null = null; let startLocalOffset = 0; let endNode: Text | null = null; let endLocalOffset = 0; while (walker.nextNode()) { const textNode = walker.currentNode as Text; const nodeStart = currentOffset; const nodeEnd = currentOffset + textNode.length; if (!startNode && pStart >= nodeStart && pStart < nodeEnd) { startNode = textNode; startLocalOffset = pStart - nodeStart; } if (!endNode && pEnd > nodeStart && pEnd <= nodeEnd) { endNode = textNode; endLocalOffset = pEnd - nodeStart; } currentOffset = nodeEnd; if (startNode && endNode) break; } if (!startNode || !endNode) continue; const tagName = fmt.type === "bold" ? "strong" : fmt.type === "code" ? "code" : "em"; const className = fmt.type === "bold" ? "step-preview-bold" : fmt.type === "code" ? "step-preview-code" : "step-preview-italic"; // If start and end are in the same text node, wrap directly if (startNode === endNode) { try { const range = document.createRange(); range.setStart(startNode, startLocalOffset); range.setEnd(endNode, endLocalOffset); const wrapper = document.createElement(tagName); wrapper.className = className; const fragment = range.extractContents(); wrapper.appendChild(fragment); range.insertNode(wrapper); } catch { // DOM manipulation can fail if range crosses element boundaries } } else { // Multi-node range (e.g. code spanning multiple lines/divs): // collect all text nodes in the range, then wrap each one individually const textNodes: { node: Text; localStart: number; localEnd: number }[] = []; const walker2 = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT); let collecting = false; while (walker2.nextNode()) { const tn = walker2.currentNode as Text; if (tn === startNode) { collecting = true; textNodes.push({ node: tn, localStart: startLocalOffset, localEnd: tn.length }); } else if (tn === endNode) { textNodes.push({ node: tn, localStart: 0, localEnd: endLocalOffset }); break; } else if (collecting) { textNodes.push({ node: tn, localStart: 0, localEnd: tn.length }); } } // Wrap in reverse order to preserve offsets for (let ti = textNodes.length - 1; ti >= 0; ti--) { const { node, localStart, localEnd } = textNodes[ti]; if (localStart >= localEnd) continue; try { const range = document.createRange(); range.setStart(node, localStart); range.setEnd(node, localEnd); const wrapper = document.createElement(tagName); wrapper.className = className; const fragment = range.extractContents(); wrapper.appendChild(fragment); range.insertNode(wrapper); } catch { // skip nodes that can't be wrapped } } } } } function adjustLinksForEdit(links: LinkMeta[], editPos: number, delta: number): LinkMeta[] { return links .map((link) => { if (delta >= 0) { if (editPos <= link.start) { return { ...link, start: link.start + delta, end: link.end + delta }; } if (editPos >= link.end) { return link; } return { ...link, end: link.end + delta }; } const delEnd = editPos + Math.abs(delta); const newStart = link.start < editPos ? link.start : link.start >= delEnd ? link.start + delta : editPos; const newEnd = link.end <= editPos ? link.end : link.end >= delEnd ? link.end + delta : editPos; return { ...link, start: newStart, end: newEnd }; }) .filter((link) => link.end > link.start); } function applyLinkHighlights(preview: HTMLElement, links: LinkMeta[]) { if (links.length === 0) return; // Remove previous link highlights const existing = preview.querySelectorAll("a.step-preview-link"); for (let i = 0; i < existing.length; i++) { const el = existing[i]; const parent = el.parentNode; if (parent) { while (el.firstChild) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } } const sorted = [...links].sort((a, b) => b.start - a.start); for (const link of sorted) { const walker = document.createTreeWalker(preview, NodeFilter.SHOW_TEXT); let currentOffset = 0; let startNode: Text | null = null; let startLocalOffset = 0; let endNode: Text | null = null; let endLocalOffset = 0; while (walker.nextNode()) { const textNode = walker.currentNode as Text; const nodeStart = currentOffset; const nodeEnd = currentOffset + textNode.length; if (!startNode && link.start >= nodeStart && link.start < nodeEnd) { startNode = textNode; startLocalOffset = link.start - nodeStart; } if (!endNode && link.end > nodeStart && link.end <= nodeEnd) { endNode = textNode; endLocalOffset = link.end - nodeStart; } currentOffset = nodeEnd; if (startNode && endNode) break; } if (!startNode || !endNode) continue; try { const range = document.createRange(); range.setStart(startNode, startLocalOffset); range.setEnd(endNode, endLocalOffset); const anchor = document.createElement("a"); anchor.href = link.url; anchor.className = "step-preview-link"; const fragment = range.extractContents(); anchor.appendChild(fragment); range.insertNode(anchor); } catch { // DOM manipulation can fail if range crosses element boundaries unexpectedly } } } function markdownToPlainText(markdown: string): string { if (!markdown) { return ""; } try { const html = markdownParser?.parse ? markdownParser.parse(markdown) : markdown; if (typeof document === "undefined") { return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); } const temp = document.createElement("div"); temp.innerHTML = html; return (temp.textContent ?? "").replace(/\s+/g, " ").trim(); } catch { return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[[^\]]*]\([^)]+\)/g, "").replace(/[*_`~]/g, "").replace(/\s+/g, " ").trim(); } } const IMAGE_SYNTAX = /!\[([^\]]*)\]\(([^)]+)\)/g; /** * Render a run of plain text, turning any `![alt](url)` markdown into real * `` elements. Returns a string when there are no images (so simple text * stays a plain text node), otherwise a keyed array of strings and images. */ function renderTextWithImages(text: string, key: string): ReactNode { if (!text.includes("![")) { return text; } const nodes: ReactNode[] = []; IMAGE_SYNTAX.lastIndex = 0; let last = 0; let part = 0; let match: RegExpExecArray | null; while ((match = IMAGE_SYNTAX.exec(text)) !== null) { if (match.index > last) { nodes.push({text.slice(last, match.index)}); } nodes.push({match[1]); last = match.index + match[0].length; } if (last < text.length) { nodes.push({text.slice(last)}); } return nodes; } /** * Render a step field's markdown as a faithful, read-only reading view: the same * clean text + bold/italic/code/link decorations + inline images the live * OverType preview shows, but as plain React children (no editor, no refs, no * imperative DOM — BlockNote's node-view renderer doesn't attach refs the way a * normal React commit does, so the content must be declarative). * * Decorations are applied by slicing the plain text at every formatting/link * boundary and wrapping each segment in the same `step-preview-*` elements the * live editor uses, so all existing CSS applies unchanged. */ function renderStepFieldContent(value: string): ReactNode { const { plainText, links, formatting } = stripInlineMarkdown(value); if (!plainText) { return null; } if (formatting.length === 0 && links.length === 0) { return renderTextWithImages(plainText, "p"); } const len = plainText.length; const points = new Set([0, len]); for (const f of formatting) { points.add(Math.max(0, f.start)); points.add(Math.min(len, f.end)); } for (const l of links) { points.add(Math.max(0, l.start)); points.add(Math.min(len, l.end)); } const sorted = [...points].sort((a, b) => a - b); const out: ReactNode[] = []; for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i]; const b = sorted[i + 1]; if (a >= b) { continue; } const text = plainText.slice(a, b); const fmts = new Set( formatting.filter((f) => f.start <= a && f.end >= b).map((f) => f.type), ); const link = links.find((l) => l.start <= a && l.end >= b); let node: ReactNode = renderTextWithImages(text, `s${i}`); if (fmts.has("code")) { node = {node}; } if (fmts.has("italic")) { node = {node}; } if (fmts.has("bold")) { node = {node}; } if (link) { node = ( {node} ); } out.push({node}); } return out; } /** * Lightweight, non-interactive stand-in for {@link StepField}. Mounts no * OverType editor, observers, or event handlers — just a styled * `.bn-step-editor--preview` box whose markdown is rendered declaratively. Used * for every step that isn't currently being edited. */ export function StepFieldPreview({ value, fieldName, multiline = true, }: { value: string; fieldName?: string; multiline?: boolean; }) { const content = useMemo(() => renderStepFieldContent(value), [value]); const editorClassName = [ "bn-step-editor", multiline ? "bn-step-editor--multiline" : "", "bn-step-editor--preview", ] .filter(Boolean) .join(" "); return (
{content}
); } 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, }: StepFieldProps) { const stepSuggestions = useStepAutocomplete(); const suggestions = suggestionsOverride ?? stepSuggestions; const uploadImage = useStepImageUpload(); const fileInputRef = useRef(null); const editorContainerRef = useRef(null); const editorInstanceRef = useRef(null); const [textareaNode, setTextareaNode] = useState(null); const autoFocusRef = useRef(false); const pendingFocusRef = useRef(false); const initialValueRef = useRef(value); // Read at OverType init so the editor mounts already-collapsed in compact // mode (no tall first frame before the compact layout effect runs). const compactModeRef = useRef(compactMode); compactModeRef.current = compactMode; const onChangeRef = useRef(onChange); const [plainTextValue, setPlainTextValue] = useState(() => markdownToPlainText(value)); const [isFocused, setIsFocused] = useState(false); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0); const [showAllSuggestions, setShowAllSuggestions] = useState(false); const [suggestWhileTyping, setSuggestWhileTyping] = useState(true); // Suggestions opened explicitly (button / Ctrl+Space) keep filtering while // typing even when the host disabled suggest-while-typing. const [suggestionsRequested, setSuggestionsRequested] = useState(false); const [isUploading, setIsUploading] = useState(false); const [previewImageUrl, setPreviewImageUrl] = useState(null); const [showLinkPopover, setShowLinkPopover] = useState(false); const linkSelectionRef = useRef<{ start: number; end: number; text: string } | null>(null); const linksRef = useRef([]); const formattingRef = useRef([]); const undoStackRef = useRef([]); const redoStackRef = useRef([]); const caretRef = useRef(null); const prevTextRef = useRef(""); const isSyncingRef = useRef(false); const [cursorLink, setCursorLink] = useState(null); const [activeFormats, setActiveFormats] = useState>(new Set()); const [linkActive, setLinkActive] = useState(false); const Components = useComponentsContext(); const resolvedPlaceholder = placeholder ?? ""; useEffect(() => { onChangeRef.current = onChange; }, [onChange]); const pushUndoSnapshot = useCallback( ( text: string, formatting: FormattingMeta[], links: LinkMeta[], cursorStart: number, cursorEnd: number, ) => { const lastSnapshot = undoStackRef.current[undoStackRef.current.length - 1]; if ( lastSnapshot && lastSnapshot.text === text && JSON.stringify(lastSnapshot.formatting) === JSON.stringify(formatting) && JSON.stringify(lastSnapshot.links) === JSON.stringify(links) ) { return; } undoStackRef.current = [ ...undoStackRef.current.slice(-(UNDO_STACK_LIMIT - 1)), { text, formatting: [...formatting], links: [...links], cursorStart, cursorEnd }, ]; redoStackRef.current = []; }, [], ); const handleEditorChange = useCallback((nextValue: string) => { if (isSyncingRef.current) return; if (nextValue === prevTextRef.current) return; const prevText = prevTextRef.current; const delta = nextValue.length - prevText.length; // Find where the edit happened by comparing old and new text let editPos = 0; const minLen = Math.min(prevText.length, nextValue.length); while (editPos < minLen && prevText[editPos] === nextValue[editPos]) { editPos++; } // Capture pre-edit state for undo BEFORE mutating const prevFormatting = [...formattingRef.current]; const prevLinks = [...linksRef.current]; linksRef.current = adjustLinksForEdit(linksRef.current, editPos, delta); formattingRef.current = adjustFormattingForEdit(formattingRef.current, editPos, delta); // Push pre-edit state to undo stack pushUndoSnapshot(prevText, prevFormatting, prevLinks, editPos, editPos); prevTextRef.current = nextValue; const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current); setSuggestWhileTyping(isStepAutocompleteOnTypeEnabled(editorContainerRef.current)); setPlainTextValue((prev) => { const normalized = markdownToPlainText(markdown); return prev === normalized ? prev : normalized; }); onChangeRef.current?.(markdown); }, [pushUndoSnapshot]); useIsomorphicLayoutEffect(() => { const container = editorContainerRef.current; if (!container) { return; } const { plainText, links, formatting } = stripInlineMarkdown(initialValueRef.current); linksRef.current = links; formattingRef.current = formatting; prevTextRef.current = plainText; // Push initial state as the baseline undo snapshot undoStackRef.current = [ { text: plainText, formatting: [...formatting], links: [...links], cursorStart: 0, cursorEnd: 0 }, ]; redoStackRef.current = []; const [instance] = OverType.init(container, { value: plainText, placeholder: resolvedPlaceholder, autoResize: multiline, // Seed the compact floor at init so a clicked step paints already // collapsed — the compact layout effect below keeps it in sync after. minHeight: compactModeRef.current ? "0px" : multiline ? "4rem" : "2.5rem", padding: "0.5rem 0.75rem", fontSize: "0.95rem", onChange: handleEditorChange, }); if (compactModeRef.current && instance.textarea) { instance.textarea.rows = 1; } // OverType hard-codes spellcheck="false"; drop it so the textarea inherits // the host's spellcheck setting (enabled by default). instance.textarea?.removeAttribute("spellcheck"); // Monkey-patch updatePreview to add link highlights const originalUpdatePreview = instance.updatePreview.bind(instance); instance.updatePreview = function () { originalUpdatePreview(); applyFormattingHighlights(this.preview, formattingRef.current, this.textarea?.value); applyLinkHighlights(this.preview, linksRef.current); }; // Force a full update through the monkey-patched pipeline instance.updatePreview(); // Safety net: re-apply formatting if the preview gets reset externally // (e.g. by the original updatePreview being called outside our monkey-patch) let isApplyingFormatting = false; const formattingObserver = new MutationObserver(() => { if (isApplyingFormatting) return; const hasFormatting = formattingRef.current.length > 0; const hasAnyFormattingElement = instance.preview.querySelector("strong.step-preview-bold, em.step-preview-italic, code.step-preview-code") !== null; if (hasFormatting && !hasAnyFormattingElement) { isApplyingFormatting = true; applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value); applyLinkHighlights(instance.preview, linksRef.current); isApplyingFormatting = false; } }); formattingObserver.observe(instance.preview, { childList: true, subtree: true }); // Create custom caret element inside the wrapper const caretEl = document.createElement("div"); caretEl.className = "bn-step-custom-caret"; instance.wrapper.appendChild(caretEl); caretRef.current = caretEl; editorInstanceRef.current = instance; setTextareaNode(instance.textarea); return () => { formattingObserver.disconnect(); caretRef.current = null; instance.destroy(); editorInstanceRef.current = null; setTextareaNode(null); }; }, [handleEditorChange, multiline, resolvedPlaceholder]); // Custom caret: position based on preview text metrics (handles bold/italic width differences) useEffect(() => { const instance = editorInstanceRef.current; const caret = caretRef.current; if (!textareaNode || !instance || !caret) return; const updateCaret = () => { const hasFormatting = formattingRef.current.length > 0; if (!hasFormatting) { caret.style.display = "none"; textareaNode.classList.remove("bn-step-caret-hidden"); return; } // Always hide native caret when formatting exists textareaNode.classList.add("bn-step-caret-hidden"); const isFocused = document.activeElement === textareaNode; if (!isFocused) { caret.style.display = "none"; return; } const pos = textareaNode.selectionStart ?? 0; const selEnd = textareaNode.selectionEnd ?? 0; // Hide custom caret when there's a selection range if (pos !== selEnd) { caret.style.display = "none"; return; } const rect = getCaretRectInPreview(instance.preview, pos, instance.textarea?.value); if (rect) { caret.style.display = "block"; caret.style.top = `${rect.top}px`; caret.style.left = `${rect.left}px`; caret.style.height = `${rect.height}px`; } else { caret.style.display = "none"; } }; const onSelectionChange = () => { if (document.activeElement === textareaNode) { updateCaret(); } }; const onBlur = () => { caret.style.display = "none"; }; const onFocus = () => { updateCaret(); }; const deferredUpdate = () => requestAnimationFrame(updateCaret); document.addEventListener("selectionchange", onSelectionChange); textareaNode.addEventListener("input", deferredUpdate); textareaNode.addEventListener("focus", onFocus); textareaNode.addEventListener("blur", onBlur); // Initial update updateCaret(); return () => { document.removeEventListener("selectionchange", onSelectionChange); textareaNode.removeEventListener("input", deferredUpdate); textareaNode.removeEventListener("focus", onFocus); textareaNode.removeEventListener("blur", onBlur); textareaNode.classList.remove("bn-step-caret-hidden"); }; }, [textareaNode]); useEffect(() => { if (pendingFocusRef.current && textareaNode) { pendingFocusRef.current = false; textareaNode.focus(); } }, [textareaNode]); useEffect(() => { if (!textareaNode || !focusSignal) { return; } textareaNode.focus(); }, [focusSignal, textareaNode]); useAutoResize({ textarea: textareaNode, enabled: multiline, onResize: useCallback(() => { const instance = editorInstanceRef.current as (OverTypeInstance & { _updateAutoHeight?: () => void }) | null; instance?._updateAutoHeight?.(); }, []), }); // 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. useIsomorphicLayoutEffect(() => { 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) { setPlainTextValue((prev) => { const normalized = markdownToPlainText(value); return prev === normalized ? prev : normalized; }); return; } const { plainText, links, formatting } = stripInlineMarkdown(value); linksRef.current = links; formattingRef.current = formatting; prevTextRef.current = plainText; if (instance.getValue() !== plainText) { isSyncingRef.current = true; instance.setValue(plainText); isSyncingRef.current = false; } else { // Even if text didn't change, formatting/links might have — re-apply highlights applyFormattingHighlights(instance.preview, formatting, instance.textarea?.value); applyLinkHighlights(instance.preview, links); } setPlainTextValue((prev) => { const normalized = markdownToPlainText(value); return prev === normalized ? prev : normalized; }); }, [value]); useEffect(() => { if (!textareaNode) { return; } if (fieldName) { textareaNode.dataset.stepField = fieldName; } else { delete textareaNode.dataset.stepField; } }, [fieldName, textareaNode]); // Block native undo/redo at the beforeinput level so the browser never // applies its own history on the textarea — our custom stack handles it. useEffect(() => { if (!textareaNode) return; const blockNativeUndoRedo = (e: InputEvent) => { if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { e.preventDefault(); } }; textareaNode.addEventListener("beforeinput", blockNativeUndoRedo as EventListener); return () => { textareaNode.removeEventListener("beforeinput", blockNativeUndoRedo as EventListener); }; }, [textareaNode]); useEffect(() => { if (!textareaNode) { return; } textareaNode.readOnly = readOnly; }, [readOnly, textareaNode]); useEffect(() => { if (!textareaNode) { return; } const handleFocus = () => { setIsFocused(true); if (showSuggestionsOnFocus && enableAutocomplete) { setShowAllSuggestions(true); } onFieldFocus?.(); }; const handleBlur = () => { setIsFocused(false); setShowAllSuggestions(false); setSuggestionsRequested(false); // Re-apply formatting highlights after blur because OverType may // re-render the preview (via debounced selectionchange) and strip them. const instance = editorInstanceRef.current; if (instance) { requestAnimationFrame(() => { applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value); applyLinkHighlights(instance.preview, linksRef.current); }); } }; textareaNode.addEventListener("focus", handleFocus); textareaNode.addEventListener("blur", handleBlur); return () => { textareaNode.removeEventListener("focus", handleFocus); textareaNode.removeEventListener("blur", handleBlur); }; }, [enableAutocomplete, onFieldFocus, showSuggestionsOnFocus, textareaNode]); // Detect when cursor is inside a link for showing edit tooltip useEffect(() => { if (!textareaNode) return; const checkCursorInLink = () => { const pos = textareaNode.selectionStart; const isCollapsed = textareaNode.selectionStart === textareaNode.selectionEnd; if (!isCollapsed) { setCursorLink(null); return; } const found = linksRef.current.find((l) => pos >= l.start && pos <= l.end); setCursorLink(found ?? null); }; textareaNode.addEventListener("click", checkCursorInLink); textareaNode.addEventListener("keyup", checkCursorInLink); textareaNode.addEventListener("blur", () => setCursorLink(null)); return () => { textareaNode.removeEventListener("click", checkCursorInLink); textareaNode.removeEventListener("keyup", checkCursorInLink); }; }, [textareaNode]); useEffect(() => { if (!autoFocus || autoFocusRef.current || !textareaNode) { return; } autoFocusRef.current = true; const focus = () => { textareaNode.focus(); if (showSuggestionsOnFocus && enableAutocomplete) { setShowAllSuggestions(true); } }; if (typeof requestAnimationFrame === "function") { const frame = requestAnimationFrame(focus); return () => cancelAnimationFrame(frame); } const timeout = setTimeout(focus, 0); return () => clearTimeout(timeout); }, [autoFocus, enableAutocomplete, showSuggestionsOnFocus, textareaNode]); const insertImageMarkdown = useCallback( (url: string) => { const instance = editorInstanceRef.current; const textarea = textareaNode; if (!instance || !textarea) { return; } const currentValue = instance.getValue(); const start = textarea.selectionStart ?? currentValue.length; const end = textarea.selectionEnd ?? currentValue.length; const before = currentValue.slice(0, start); const after = currentValue.slice(end); const needsBeforeNewline = before.length > 0 && !before.endsWith("\n"); const needsAfterNewline = after.length > 0 && !after.startsWith("\n"); const insertText = `${needsBeforeNewline ? "\n" : ""}![](${url})${needsAfterNewline ? "\n" : ""}`; const nextValue = `${before}${insertText}${after}`; // setValue triggers updatePreview → handleEditorChange which reconstructs markdown with links instance.setValue(nextValue); requestAnimationFrame(() => { textarea.selectionStart = start + insertText.length; textarea.selectionEnd = start + insertText.length; textarea.focus(); }); }, [textareaNode], ); useEffect(() => { if (!textareaNode) { return; } const handlePaste = async (event: ClipboardEvent) => { if (!onImageFile && !(enableImageUpload && uploadImage)) { return; } const items = Array.from(event.clipboardData?.items ?? []); const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/")); const file = imageItem?.getAsFile(); if (!file) { return; } event.preventDefault(); if (onImageFile) { await onImageFile(file); return; } if (enableImageUpload && uploadImage) { try { const result = await uploadImage(file); if (result?.url) { insertImageMarkdown(result.url); } } catch (error) { console.error("Failed to upload pasted image", error); } } }; const listener = (event: ClipboardEvent) => { void handlePaste(event); }; textareaNode.addEventListener("paste", listener); return () => { textareaNode.removeEventListener("paste", listener); }; }, [enableImageUpload, insertImageMarkdown, onImageFile, textareaNode, uploadImage]); const handleToolbarAction = useCallback( (action: "toggleBold" | "toggleItalic" | "toggleCode") => { const instance = editorInstanceRef.current; if (!textareaNode || !instance) { return; } textareaNode.focus(); const fmtType: "bold" | "italic" | "code" = action === "toggleBold" ? "bold" : action === "toggleCode" ? "code" : "italic"; const rawStart = textareaNode.selectionStart ?? 0; const rawEnd = textareaNode.selectionEnd ?? 0; // Trim leading/trailing whitespace from the selection so that // formatting markers wrap only the meaningful content. const selectedText = textareaNode.value.slice(rawStart, rawEnd); const leadingWs = selectedText.match(/^(\s*)/)?.[1].length ?? 0; const trailingWs = selectedText.match(/(\s*)$/)?.[1].length ?? 0; const start = rawStart + leadingWs; const end = rawEnd - trailingWs; // If selection is all whitespace, nothing to format if (start >= end) return; // Check if selection is already formatted const existingIdx = formattingRef.current.findIndex( (f) => f.type === fmtType && f.start <= start && f.end >= end, ); // Save current state for undo before modifying const currentText = instance.getValue(); pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, start, end); if (existingIdx !== -1) { // Remove formatting formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx); } else if (start !== end) { const cleaned = applyInlineExclusion( formattingRef.current, linksRef.current, start, end, fmtType, ); formattingRef.current = [...cleaned.formatting, { start, end, type: fmtType }]; linksRef.current = cleaned.links; } else { // No selection — nothing to format return; } const currentValue = instance.getValue(); prevTextRef.current = currentValue; const markdown = buildFullMarkdown(currentValue, linksRef.current, formattingRef.current); onChangeRef.current?.(markdown); setPlainTextValue(markdownToPlainText(markdown)); // Re-apply highlights applyFormattingHighlights(instance.preview, formattingRef.current, textareaNode?.value); applyLinkHighlights(instance.preview, linksRef.current); }, [textareaNode, pushUndoSnapshot], ); const updateActiveFormats = useCallback(() => { if (!textareaNode) return; const selStart = textareaNode.selectionStart ?? 0; const selEnd = textareaNode.selectionEnd ?? 0; const next = getActiveFormats(formattingRef.current, selStart, selEnd); setActiveFormats((prev) => { if (prev.size === next.size && [...prev].every((t) => next.has(t))) return prev; return next; }); const hasLink = selStart !== selEnd && linksRef.current.some((l) => selStart < l.end && selEnd > l.start); setLinkActive(hasLink); }, [textareaNode]); useEffect(() => { if (!textareaNode) return; const onSelectionChange = () => { if (document.activeElement === textareaNode) { updateActiveFormats(); } }; const onBlur = () => { setActiveFormats(new Set()); setLinkActive(false); }; document.addEventListener("selectionchange", onSelectionChange); textareaNode.addEventListener("keyup", updateActiveFormats); textareaNode.addEventListener("mouseup", updateActiveFormats); textareaNode.addEventListener("blur", onBlur); return () => { document.removeEventListener("selectionchange", onSelectionChange); textareaNode.removeEventListener("keyup", updateActiveFormats); textareaNode.removeEventListener("mouseup", updateActiveFormats); textareaNode.removeEventListener("blur", onBlur); }; }, [textareaNode, updateActiveFormats]); const linkPopoverRef = useRef(null); // Close link popover on outside click useEffect(() => { if (!showLinkPopover) return; const handleMouseDown = (event: MouseEvent) => { const popover = linkPopoverRef.current; if (popover && !popover.contains(event.target as Node)) { setShowLinkPopover(false); linkSelectionRef.current = null; } }; document.addEventListener("mousedown", handleMouseDown); return () => document.removeEventListener("mousedown", handleMouseDown); }, [showLinkPopover]); const handleOpenLinkPopover = useCallback(() => { if (!textareaNode) { return; } const start = textareaNode.selectionStart ?? 0; const end = textareaNode.selectionEnd ?? 0; const text = textareaNode.value.slice(start, end); linkSelectionRef.current = { start, end, text }; setShowLinkPopover(true); }, [textareaNode]); const handleEditLink = useCallback( (url: string, text: string) => { const instance = editorInstanceRef.current; const sel = linkSelectionRef.current; if (!instance || !sel || !url) { setShowLinkPopover(false); linkSelectionRef.current = null; return; } const currentValue = instance.getValue(); // Push undo snapshot before link edit pushUndoSnapshot(currentValue, formattingRef.current, linksRef.current, sel.start, sel.end); const linkText = text || sel.text || url; // Replace selected text with link display text (no markdown syntax in textarea) const before = currentValue.slice(0, sel.start); const after = currentValue.slice(sel.end); const nextValue = `${before}${linkText}${after}`; // Remove any existing link that overlaps this selection, then add the new one const delta = linkText.length - (sel.end - sel.start); const adjustedLinks = adjustLinksForEdit( linksRef.current.filter((l) => !(l.start < sel.end && l.end > sel.start)), sel.start, delta, ); const newLink: LinkMeta = { start: sel.start, end: sel.start + linkText.length, url }; linksRef.current = [...adjustedLinks, newLink]; // Links are exclusive with bold/italic/code: strip any formatting that // overlaps the original selection before shifting positions. const keptFormatting = formattingRef.current.filter( (f) => f.start >= sel.end || f.end <= sel.start, ); formattingRef.current = adjustFormattingForEdit(keptFormatting, sel.start, delta); prevTextRef.current = nextValue; isSyncingRef.current = true; instance.setValue(nextValue); isSyncingRef.current = false; const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current); onChangeRef.current?.(markdown); setPlainTextValue(markdownToPlainText(markdown)); setShowLinkPopover(false); linkSelectionRef.current = null; setCursorLink(null); requestAnimationFrame(() => textareaNode?.focus()); }, [textareaNode, pushUndoSnapshot], ); const handleRemoveLink = useCallback(() => { const instance = editorInstanceRef.current; // Push undo snapshot before link removal if (instance) { const currentText = instance.getValue(); const cursorPos = instance.textarea?.selectionStart ?? 0; pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, cursorPos, cursorPos); } linksRef.current = linksRef.current.filter((l) => l !== cursorLink); setCursorLink(null); if (instance) { const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current); onChangeRef.current?.(markdown); // Re-apply highlights since links changed applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value); applyLinkHighlights(instance.preview, linksRef.current); } }, [cursorLink, pushUndoSnapshot]); const suggestionPool = useMemo(() => { if (!suggestionFilter) { return suggestions; } const filtered = suggestions.filter(suggestionFilter); return filtered.length > 0 ? filtered : suggestions; }, [suggestionFilter, suggestions]); const normalizedQuery = normalizePlainText(plainTextValue); useEffect(() => { if (normalizedQuery.length > 0) { setShowAllSuggestions(false); } }, [normalizedQuery]); const filteredSuggestions = useMemo(() => { if (!enableAutocomplete) { return []; } const pool = showAllSuggestions || !normalizedQuery ? suggestionPool : suggestionPool.filter((item) => normalizePlainText(item.title).startsWith(normalizedQuery)); return pool.slice(0, 8); }, [enableAutocomplete, normalizedQuery, showAllSuggestions, suggestionPool]); const hasExactMatch = filteredSuggestions.some( (item) => normalizePlainText(item.title) === normalizedQuery, ); const shouldShowAutocomplete = enableAutocomplete && isFocused && filteredSuggestions.length > 0 && (!hasExactMatch || showAllSuggestions) && (showAllSuggestions || (normalizedQuery.length >= 1 && (suggestWhileTyping || suggestionsRequested))); useEffect(() => { setActiveSuggestionIndex(0); }, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]); const extractedImages = useMemo(() => { if (!value) { return []; } const regex = /!\[([^\]]*)\]\(([^)]+)\)/g; const results: ExtractedImage[] = []; let match: RegExpExecArray | null; while ((match = regex.exec(value)) !== null) { const [, alt = "", url = ""] = match; results.push({ id: `${match.index}-${url}-${results.length}`, url, alt, start: match.index, end: match.index + match[0].length, markdown: match[0], }); } return results; }, [value]); const handleRemoveImage = useCallback( (image: ExtractedImage) => { // Push undo snapshot before image removal if (editorInstanceRef.current) { const currentText = editorInstanceRef.current.getValue(); pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, image.start, image.end); } const before = value.slice(0, image.start); const after = value.slice(image.end); const nextValue = `${before}${after}`.replace(/\n{3,}/g, "\n\n"); if (editorInstanceRef.current) { editorInstanceRef.current.setValue(nextValue); } onChangeRef.current?.(nextValue); setPlainTextValue(markdownToPlainText(nextValue)); setPreviewImageUrl((prev) => (prev === image.url ? null : prev)); }, [value, pushUndoSnapshot], ); const handleImageClick = useCallback((url: string) => { setPreviewImageUrl(url); }, []); const focusAdjacentField = useCallback( (direction: 1 | -1) => { if (!textareaNode || typeof document === "undefined") { return false; } const selector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"], [data-step-field]'; const focusable = Array.from(document.querySelectorAll(selector)).filter((element) => { if (element.getAttribute("aria-hidden") === "true" || element.tabIndex === -1 || element.hasAttribute("disabled")) { return false; } const isVisible = element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0; return isVisible; }); const currentIndex = focusable.findIndex((element) => element === textareaNode); const target = currentIndex === -1 ? null : focusable[currentIndex + direction]; if (!target) { return false; } target.focus(); return true; }, [textareaNode], ); const applySuggestion = useCallback( (suggestion: Suggestion) => { const escaped = escapeMarkdownText(suggestion.title); const instance = editorInstanceRef.current; if (instance) { // Push undo snapshot before applying suggestion const currentText = instance.getValue(); const cursorPos = textareaNode?.selectionStart ?? 0; pushUndoSnapshot(currentText, formattingRef.current, linksRef.current, cursorPos, cursorPos); instance.setValue(escaped); } setPlainTextValue(suggestion.title); onChangeRef.current?.(escaped); onSuggestionSelect?.(suggestion); setActiveSuggestionIndex(0); setShowAllSuggestions(false); setSuggestionsRequested(false); requestAnimationFrame(() => { textareaNode?.focus(); if (textareaNode) { textareaNode.selectionStart = escaped.length; textareaNode.selectionEnd = escaped.length; } }); }, [onSuggestionSelect, textareaNode, pushUndoSnapshot], ); const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(null); useEffect(() => { keydownHandlerRef.current = (event: KeyboardEvent) => { if (readOnly) { const openKeys = enableAutocomplete && (event.metaKey || event.ctrlKey) && AUTOCOMPLETE_TRIGGER_KEYS.has(event.code); if (!READ_ONLY_ALLOWED_KEYS.has(event.key) && !openKeys) { event.preventDefault(); return; } } // Intercept Ctrl+B / Ctrl+I to use our formatting system instead of OverType's const modKey = navigator.platform?.toLowerCase().includes("mac") ? event.metaKey : event.ctrlKey; if (modKey && !event.shiftKey) { if (event.key === "b" || event.key === "B") { event.preventDefault(); event.stopImmediatePropagation(); handleToolbarAction("toggleBold"); return; } if (event.key === "i" || event.key === "I") { event.preventDefault(); event.stopImmediatePropagation(); handleToolbarAction("toggleItalic"); return; } if (event.key === "e" || event.key === "E") { event.preventDefault(); event.stopImmediatePropagation(); handleToolbarAction("toggleCode"); return; } if (event.key === "a" || event.key === "A") { event.stopPropagation(); return; } if (event.key === "z" || event.key === "Z") { event.preventDefault(); event.stopImmediatePropagation(); const stack = undoStackRef.current; if (stack.length === 0) return; const instance = editorInstanceRef.current; if (!instance) return; // Push current state to redo stack const currentText = instance.getValue(); redoStackRef.current = [ ...redoStackRef.current, { text: currentText, formatting: [...formattingRef.current], links: [...linksRef.current], cursorStart: textareaNode?.selectionStart ?? 0, cursorEnd: textareaNode?.selectionEnd ?? 0, }, ]; // Pop from undo stack const prev = stack[stack.length - 1]; undoStackRef.current = stack.slice(0, -1); // Restore state formattingRef.current = prev.formatting; linksRef.current = prev.links; prevTextRef.current = prev.text; isSyncingRef.current = true; instance.setValue(prev.text); isSyncingRef.current = false; if (textareaNode) { textareaNode.selectionStart = prev.cursorStart; textareaNode.selectionEnd = prev.cursorEnd; } const markdown = buildFullMarkdown(prev.text, prev.links, prev.formatting); onChangeRef.current?.(markdown); setPlainTextValue(markdownToPlainText(markdown)); applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value); applyLinkHighlights(instance.preview, linksRef.current); return; } } if (modKey && event.shiftKey && (event.key === "z" || event.key === "Z")) { event.preventDefault(); event.stopImmediatePropagation(); const stack = redoStackRef.current; if (stack.length === 0) return; const instance = editorInstanceRef.current; if (!instance) return; // Push current state to undo stack const currentText = instance.getValue(); undoStackRef.current = [ ...undoStackRef.current, { text: currentText, formatting: [...formattingRef.current], links: [...linksRef.current], cursorStart: textareaNode?.selectionStart ?? 0, cursorEnd: textareaNode?.selectionEnd ?? 0, }, ]; // Pop from redo stack const next = stack[stack.length - 1]; redoStackRef.current = stack.slice(0, -1); // Restore state formattingRef.current = next.formatting; linksRef.current = next.links; prevTextRef.current = next.text; isSyncingRef.current = true; instance.setValue(next.text); isSyncingRef.current = false; if (textareaNode) { textareaNode.selectionStart = next.cursorStart; textareaNode.selectionEnd = next.cursorEnd; } const markdown = buildFullMarkdown(next.text, next.links, next.formatting); onChangeRef.current?.(markdown); setPlainTextValue(markdownToPlainText(markdown)); applyFormattingHighlights(instance.preview, next.formatting, instance.textarea?.value); applyLinkHighlights(instance.preview, next.links); return; } if (enableAutocomplete && shouldShowAutocomplete) { if (event.key === "ArrowDown") { event.preventDefault(); setActiveSuggestionIndex((prev) => prev + 1 >= filteredSuggestions.length ? 0 : prev + 1, ); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveSuggestionIndex((prev) => prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1, ); return; } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); const suggestion = filteredSuggestions[activeSuggestionIndex] ?? filteredSuggestions[0]; if (suggestion) { applySuggestion(suggestion); } return; } } if ( enableAutocomplete && (event.metaKey || event.ctrlKey) && (AUTOCOMPLETE_TRIGGER_KEYS.has(event.code) || AUTOCOMPLETE_TRIGGER_KEYS.has(event.key)) ) { event.preventDefault(); setShowAllSuggestions(true); setSuggestionsRequested(true); return; } if (disableNewlines && (event.key === "Enter" || event.code === "Enter")) { event.preventDefault(); event.stopImmediatePropagation(); return; } if (event.key === "Tab") { const moved = focusAdjacentField(event.shiftKey ? -1 : 1); if (moved) { event.preventDefault(); event.stopImmediatePropagation(); } } }; }, [activeSuggestionIndex, applySuggestion, disableNewlines, enableAutocomplete, filteredSuggestions, focusAdjacentField, handleToolbarAction, readOnly, shouldShowAutocomplete]); useEffect(() => { if (!textareaNode) { return; } const listener = (event: KeyboardEvent) => { keydownHandlerRef.current?.(event); }; const keydownOptions: AddEventListenerOptions = { capture: true }; textareaNode.addEventListener("keydown", listener, keydownOptions); return () => { textareaNode.removeEventListener("keydown", listener, keydownOptions); }; }, [textareaNode]); const editorClassName = [ "bn-step-editor", multiline ? "bn-step-editor--multiline" : "", isFocused ? "bn-step-editor--focused" : "", readOnly ? "bn-step-editor--readonly" : "", ] .filter(Boolean) .join(" "); const inputClassName = [ "bn-step-field__input", multiline ? "bn-step-field__input--multiline" : "", isFocused ? "bn-step-field__input--focused" : "", readOnly ? "bn-step-field__input--readonly" : "", ] .filter(Boolean) .join(" "); const showToolbar = !compact && (showFormattingButtons || (enableImageUpload && uploadImage && showImageButton) || Boolean(rightAction) || enableAutocomplete); return (
{showLabel && (
{labelToggle ? ( { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); labelToggle.onClick(); } }} aria-expanded={labelToggle.expanded} > {label} ) : ( {label} )}
{labelAction &&
{labelAction}
}
)}
{ if (event.target === editorContainerRef.current) { if (textareaNode) { textareaNode.focus(); } else { pendingFocusRef.current = true; } } }} /> {cursorLink && isFocused && (
{cursorLink.url.length > 40 ? `${cursorLink.url.slice(0, 40)}...` : cursorLink.url} event.stopPropagation()} tabIndex={-1} >
)} {showToolbar && (
{showFormattingButtons && ( <> )} {enableImageUpload && uploadImage && showImageButton && ( )} {showFormattingButtons && Components && (
{ const sel = linkSelectionRef.current; if (!sel) return ""; const existing = linksRef.current.find((l) => l.start < sel.end && l.end > sel.start); return existing?.url ?? ""; })()} text={linkSelectionRef.current?.text ?? ""} editLink={handleEditLink} />
)} {enableAutocomplete && ( <>
)} {rightAction}
)}
{enableImageUpload && ( ) => { const file = event.target.files?.[0]; if (!file || !uploadImage) { return; } try { setIsUploading(true); const response = await uploadImage(file); if (response?.url) { insertImageMarkdown(response.url); } } catch (error) { console.error("Failed to upload image", error); } finally { setIsUploading(false); event.target.value = ""; } }} /> )} {extractedImages.length > 0 && (
{extractedImages.map((image) => (
))}
)} {shouldShowAutocomplete && (
{filteredSuggestions.map((suggestion, index) => ( ))}
)} {previewImageUrl && (
setPreviewImageUrl(null)} >
event.stopPropagation()} > Full size step
)}
); }