import { markdown } from "@codemirror/lang-markdown"; import { EditorState, Prec, type Extension, type Range, type SelectionRange, } from "@codemirror/state"; import { Decoration, EditorView, keymap, placeholder, ViewPlugin, type DecorationSet, type ViewUpdate, } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab, } from "@codemirror/commands"; import { useEffect, useRef } from "react"; import { collectInlineRanges, ensureMarkdownStyles } from "./markdown"; export type InputProps = { ref?: HTMLDivElement; placeholder?: string; value: string; setValue: (value: string) => void; onSubmit?: () => void; invertEnterBehavior?: boolean; }; type TokenRange = { from: number; to: number; }; const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" }); const strongDecoration = Decoration.mark({ class: "tm-md-strong" }); const emDecoration = Decoration.mark({ class: "tm-md-em" }); const delDecoration = Decoration.mark({ class: "tm-md-del" }); const codeDecoration = Decoration.mark({ class: "tm-md-code" }); const linkDecoration = Decoration.mark({ class: "tm-md-link" }); const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" }); /** * Builds markdown styling decorations every time the document or cursor selection changes. * Token delimiters are hidden unless the cursor is currently intersecting that token range. */ const markdownDecorations = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = buildDecorations(view); } update(update: ViewUpdate) { if (update.docChanged || update.selectionSet || update.viewportChanged) { this.decorations = buildDecorations(update.view); } } }, { decorations: (instance: { decorations: DecorationSet }) => instance.decorations, }, ); /** * Executes Input. * @param props Parameter props. * @returns unknown. */ export default function Input(props: InputProps) { ensureMarkdownStyles(); const elementRef = useRef(null); const viewRef = useRef(undefined); const ignoreSyncRef = useRef(false); const onSubmitRef = useRef(props.onSubmit); const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior)); useEffect(() => { onSubmitRef.current = props.onSubmit; invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior); }, [props.onSubmit, props.invertEnterBehavior]); useEffect(() => { if (!elementRef.current) return; const state = EditorState.create({ doc: props.value, extensions: createEditorExtensions( (value) => { ignoreSyncRef.current = true; props.setValue(value); }, () => props.placeholder, () => invertEnterBehaviorRef.current, () => onSubmitRef.current?.(), ), }); viewRef.current = new EditorView({ state, parent: elementRef.current, }); return () => { viewRef.current?.destroy(); viewRef.current = undefined; }; // Run once to initialize/destroy the editor instance. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const editor = viewRef.current; if (!editor) return; const next = props.value; const current = editor.state.doc.toString(); if (ignoreSyncRef.current) { ignoreSyncRef.current = false; return; } if (next === current) return; editor.dispatch({ changes: { from: 0, to: current.length, insert: next, }, }); }, [props.value]); return
; } /** * Executes createEditorExtensions. * @param onChange Parameter onChange. * @param getPlaceholder Parameter getPlaceholder. * @param getInvertEnterBehavior Parameter getInvertEnterBehavior. * @param onSubmit Parameter onSubmit. * @returns Extension[]. */ function createEditorExtensions( onChange: (value: string) => void, getPlaceholder: () => string | undefined, getInvertEnterBehavior: () => boolean, onSubmit: () => void, ): Extension[] { const customEnterKeymap = keymap.of([ { key: "Shift-Enter", run: () => { if (!getInvertEnterBehavior()) { return false; } onSubmit(); return true; }, }, { key: "Enter", run: () => { if (getInvertEnterBehavior()) { return false; } onSubmit(); return true; }, }, ]); return [ history(), markdown(), keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), Prec.highest(customEnterKeymap), EditorView.lineWrapping, placeholder(getPlaceholder() ?? ""), EditorView.updateListener.of((update: ViewUpdate) => { if (!update.docChanged) return; onChange(update.state.doc.toString()); }), EditorView.theme({ "&": { fontSize: "1rem", }, "&.cm-editor": { width: "100%", }, }), EditorView.editorAttributes.of({ class: "tm-md-editor", spellcheck: "true", "aria-label": "Markdown input", }), markdownDecorations, ]; } /** * Executes buildDecorations. * @param view Parameter view. * @returns DecorationSet. */ function buildDecorations(view: EditorView): DecorationSet { const builder: Range[] = []; const selections = view.state.selection.ranges.map( (range: SelectionRange) => ({ from: range.from, to: range.to, }), ); let codeFenceOpen = false; for ( let lineNumber = 1; lineNumber <= view.state.doc.lines; lineNumber += 1 ) { const line = view.state.doc.line(lineNumber); const text = line.text; const lineFrom = line.from; const trimmed = text.trim(); const fence = text.match(/^```\s*([^`]*)$/); if (fence) { const ticksStart = lineFrom + text.indexOf("```"); const ticksEnd = ticksStart + 3; addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd }); if (trimmed.length > 3) { addHiddenToken(builder, selections, { from: ticksEnd, to: line.to, }); } codeFenceOpen = !codeFenceOpen; continue; } if (codeFenceOpen) { builder.push(codeLineDecoration.range(lineFrom)); continue; } const heading = text.match(/^(#{1,6})\s+/); if (heading) { const markerLength = heading[0].length; addHiddenToken(builder, selections, { from: lineFrom, to: lineFrom + markerLength, }); const level = heading[1].length; const headingClass = Decoration.mark({ class: `tm-md-heading tm-md-h${String(level)}`, }); const contentFrom = lineFrom + markerLength; if (contentFrom < line.to) { builder.push(headingClass.range(contentFrom, line.to)); } } const quote = text.match(/^>\s?/); if (quote) { addHiddenToken(builder, selections, { from: lineFrom, to: lineFrom + quote[0].length, }); } const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/); if (unordered) { const markerStart = lineFrom + unordered[1].length; const markerEnd = markerStart + unordered[2].length + 1; addHiddenToken(builder, selections, { from: markerStart, to: markerEnd }); const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/); if (checkbox) { const checkboxStart = lineFrom + unordered[0].lastIndexOf("["); addHiddenToken(builder, selections, { from: checkboxStart, to: checkboxStart + checkbox[0].length, }); } } const ordered = text.match(/^(\s*)(\d+\.)\s+/); if (ordered) { const markerStart = lineFrom + ordered[1].length; addHiddenToken(builder, selections, { from: markerStart, to: markerStart + ordered[2].length + 1, }); } if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) { if (lineFrom < line.to) { builder.push( Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to), ); } continue; } const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test( text, ); if (tableSeparator) { if (lineFrom < line.to) { builder.push( Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to), ); } continue; } const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom); for (const range of styleRanges) { if (range.from >= range.to) continue; if (range.className === "tm-md-strong") { builder.push(strongDecoration.range(range.from, range.to)); } else if (range.className === "tm-md-em") { builder.push(emDecoration.range(range.from, range.to)); } else if (range.className === "tm-md-del") { builder.push(delDecoration.range(range.from, range.to)); } else if (range.className === "tm-md-code") { builder.push(codeDecoration.range(range.from, range.to)); } else if (range.className === "tm-md-link") { builder.push(linkDecoration.range(range.from, range.to)); } } for (const token of tokenRanges) { addHiddenToken(builder, selections, token); } } return Decoration.set(builder, true); } /** * Keeps markdown syntax visible only when user selection intersects the token. * This preserves cursor predictability and cross-token selection while still hiding syntax during reading. */ function addHiddenToken( builder: Range[], selections: ReadonlyArray<{ from: number; to: number }>, token: TokenRange, ): void { if (token.from >= token.to) return; const overlapsSelection = selections.some((selection) => { const selectionFrom = Math.min(selection.from, selection.to); const selectionTo = Math.max(selection.from, selection.to); if (selectionFrom === selectionTo) { return selectionFrom >= token.from && selectionFrom <= token.to; } return selectionFrom < token.to && selectionTo > token.from; }); if (overlapsSelection) return; builder.push(hiddenTokenDecoration.range(token.from, token.to)); }