/** * MarkdownEditor — a field for writing markdown, and a way to see it rendered. * * ```tsx * const [draft, setDraft] = useState(''); * * * ``` * * ## Writing and reading are two modes, not two panes * * Side by side is how this is done on a desktop, and it does not survive the * trip to a phone: two columns of a phone's width are two columns too narrow to * read, and the keyboard covers the bottom half of the screen exactly when the * writer is using it. So there is one pane and a switch — write, or read what * you wrote. The toolbar carries the switch, because the toolbar is the one * thing on screen in both modes. * * ## The toolbar edits the selection * * Every button here is a function of the text and where the caret is in it, and * the answer that matters is not which characters get inserted but where the * caret lands afterwards. Bolding a selected phrase leaves it selected, so it * can be italicised next; bolding nothing puts the caret between the new * markers, where the writing is about to go; and pressing the same button again * takes the markers off. Those rules live in `markdown-transforms.ts` as pure * functions over `(text, selection)`, away from anything to do with a field. * * ## Why it renders through Response * * The preview is `Response`, the same reader that renders a model's answer, so * markdown means the same thing everywhere in the library and there is one * parser to be right rather than two to keep in step. It renders through * `Typography`, `CodeBlock` and `Table`, which is to say through the app's own * type and colours — a preview that looked like a document viewer would be * showing the writer something they are not going to ship. * * Nothing here reaches for a platform markdown renderer. `Text` in SwiftUI can * parse markdown and Jetpack Compose's cannot, so a platform-backed editor * would render on one of the two and be a plain string on the other. * * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`). */ import { Fragment, createContext, forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from 'react'; import { Pressable, ScrollView, View, type NativeSyntheticEvent, type TextInput, type TextInputSelectionChangeEventData, type ViewProps, } from 'react-native'; import { tv } from 'tailwind-variants'; import { BoldIcon, CodeIcon, EyeIcon, HeadingIcon, ImageIcon, ItalicIcon, LinkIcon, ListIcon, ListOrderedIcon, PencilIcon, QuoteIcon, } from '../../icons'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { Button } from '../button'; import { ButtonGroup } from '../button-group'; import { Response } from '../response'; import { Textarea, type TextareaProps } from '../textarea'; import { continueList, hasFence, hasLinePrefix, hasOrderedList, hasWrap, insertImage, insertLink, toggleFence, toggleLinePrefix, toggleOrderedList, toggleWrap, type EditResult, type EditorSelection, } from './markdown-transforms'; export type { EditorSelection, EditResult } from './markdown-transforms'; /** Which pane the editor is showing. */ export type MarkdownEditorMode = 'write' | 'preview'; /** A formatting action the toolbar can offer. */ export type MarkdownEditorAction = | 'bold' | 'italic' | 'heading' | 'quote' | 'code' | 'bulletList' | 'orderedList' | 'link' | 'image'; /** The actions a toolbar shows when it is not told which to show. */ const DEFAULT_ACTIONS: MarkdownEditorAction[] = [ 'bold', 'italic', 'heading', 'bulletList', 'orderedList', 'quote', 'code', 'link', ]; /** * The same actions in the capsule, grouped. * * Eight rather than nine: the capsule is a fixed row of targets with hairlines * between its groups, and `quote` is the one a writer reaches for least. It is * still available — pass `actions` and it appears in whatever grouping the * families below produce. */ const DEFAULT_PILL_ACTIONS: MarkdownEditorAction[] = [ 'bold', 'italic', 'heading', 'link', 'image', 'orderedList', 'bulletList', 'code', ]; /** * Which family each action belongs to, which is what the capsule's hairlines * separate: what the words look like, what is being put into the document, and * what shape the block is. */ const FAMILY: Record = { bold: 'inline', italic: 'inline', heading: 'inline', link: 'insert', image: 'insert', orderedList: 'insert', bulletList: 'insert', code: 'block', quote: 'block', }; /** * What each action does, what it is called, and what it looks like. * * One table rather than a switch in the toolbar and another in the context: * adding an action should be adding a row here, and nowhere else. */ const ACTIONS: Record< MarkdownEditorAction, { label: string; icon: (size: number) => ReactNode; apply: (text: string, selection: EditorSelection) => EditResult; /** * Whether this action is already in effect where the caret is. * * The toolbar marks those buttons, which is what makes "pressing twice * undoes it" visible rather than something the writer has to discover. An * action that only ever inserts — a link, an image — has no such state and * says so by leaving this out. */ isActive?: (text: string, selection: EditorSelection) => boolean; } > = { bold: { label: 'Bold', icon: (size) => , apply: (text, selection) => toggleWrap(text, selection, '**'), isActive: (text, selection) => hasWrap(text, selection, '**'), }, italic: { label: 'Italic', icon: (size) => , apply: (text, selection) => toggleWrap(text, selection, '_'), isActive: (text, selection) => hasWrap(text, selection, '_'), }, heading: { label: 'Heading', icon: (size) => , apply: (text, selection) => toggleLinePrefix(text, selection, '## '), isActive: (text, selection) => hasLinePrefix(text, selection, '## '), }, quote: { label: 'Quote', icon: (size) => , apply: (text, selection) => toggleLinePrefix(text, selection, '> '), isActive: (text, selection) => hasLinePrefix(text, selection, '> '), }, code: { label: 'Code block', icon: (size) => , apply: toggleFence, isActive: hasFence, }, bulletList: { label: 'Bulleted list', icon: (size) => , apply: (text, selection) => toggleLinePrefix(text, selection, '- '), isActive: (text, selection) => hasLinePrefix(text, selection, '- '), }, orderedList: { label: 'Numbered list', icon: (size) => , apply: toggleOrderedList, isActive: hasOrderedList, }, link: { label: 'Link', icon: (size) => , apply: insertLink, }, image: { label: 'Image', icon: (size) => , apply: insertImage, }, }; const markdownEditorVariants = tv({ slots: { root: 'w-full gap-2', toolbar: 'flex-row items-center justify-between gap-2', /** * The capsule holding the formatting buttons in the `pill` toolbar. * * `shrink` and `overflow-hidden` are load-bearing. Eight 36pt targets, two * hairlines and the round button beside them come to more than a phone is * wide, and a row that cannot shrink does not wrap — it runs off the edge * of the screen taking the last two actions with it. The capsule gives up * width instead, and scrolls whatever no longer fits. */ capsule: 'min-w-0 shrink flex-row items-center overflow-hidden rounded-full border border-input bg-popover px-1.5 shadow-lg', /** One hairline between two groups of them. */ divider: 'mx-1 h-5 w-px bg-border', /** The round button outside the capsule, which switches the pane. */ escape: 'h-11 w-11 shrink-0 items-center justify-center rounded-full border border-input bg-popover shadow-lg', // The preview stands in the field's place, so it takes the field's shape. // A rendered draft that sat on the page with no edge around it would not // read as the same object the writing was in. preview: 'w-full rounded-xl border border-input bg-popover px-3 py-2.5', empty: 'py-6 text-center', }, variants: { /** * How the toolbar is drawn. * * `bar` is the row: formatting on one side, the write/preview switch on the * other, and room between them for a word count or a submit. `pill` is the * floating capsule — icon-only, grouped by hairlines, with the pane switch * as a round button beside it. The capsule has no room for anything else, * so a toolbar with children wants the bar. */ variant: { bar: {}, pill: { toolbar: 'justify-center gap-2' }, }, }, defaultVariants: { variant: 'bar' }, }); export type MarkdownEditorToolbarVariant = 'bar' | 'pill'; interface MarkdownEditorContextValue { value: string; mode: MarkdownEditorMode; setMode: (mode: MarkdownEditorMode) => void; /** Runs a transform against the live text and selection. */ run: (action: MarkdownEditorAction) => void; /** * Which actions are already in effect where the caret is. Recomputed when * the caret moves rather than on every keystroke — the toolbar is the only * thing that reads it, and it has eight buttons. */ active: ReadonlySet; /** What the field should be showing as selected, once, after an edit. */ pending: EditorSelection | undefined; setSelection: (selection: EditorSelection) => void; clearPending: () => void; onChangeText: (next: string) => void; /** Registers the field, so the root's ref can focus it. */ registerField: (field: TextInput | null) => void; disabled: boolean; } const MarkdownEditorContext = createContext(null); function useMarkdownEditor(part: string): MarkdownEditorContextValue { const ctx = useContext(MarkdownEditorContext); if (!ctx) throw new Error(`${part} must be used inside .`); return ctx; } /** * What a `ref` on the editor gives you. * * The same transforms the toolbar runs, so a caller drawing their own controls * — a keyboard accessory, a context menu, a single Bold button somewhere else * on the screen — does not have to reimplement any of them. Every one applies * to the live selection and leaves the caret where the toolbar would. */ export interface MarkdownEditorHandle { /** The container, for measuring and for anything a `View` ref is good for. */ readonly view: View | null; /** Runs one of the toolbar's actions against the current selection. */ apply: (action: MarkdownEditorAction) => void; /** Which actions are already in effect where the caret is. */ getActive: () => MarkdownEditorAction[]; /** Where the caret is, or what is selected. */ getSelection: () => EditorSelection; /** Moves the caret, or selects a range. */ setSelection: (selection: EditorSelection) => void; /** Puts the caret in the field. Switches to the writing pane if it is not up. */ focus: () => void; blur: () => void; /** Switches pane. */ setMode: (mode: MarkdownEditorMode) => void; } export interface MarkdownEditorProps extends Omit { className?: string; /** Controlled text. Leave unset and pass `defaultValue` to run uncontrolled. */ value?: string; /** Starting text when uncontrolled. */ defaultValue?: string; onValueChange?: (value: string) => void; /** Controlled pane. */ mode?: MarkdownEditorMode; /** Starting pane when uncontrolled. */ defaultMode?: MarkdownEditorMode; onModeChange?: (mode: MarkdownEditorMode) => void; /** * Stop the field being edited and the formatting actions being pressed. The * switch between the panes stays live: a draft nobody may edit is still a * draft somebody may want to read rendered. */ disabled?: boolean; /** * The parts, in the order they should stack. Left out, the editor draws its * toolbar, its field and its preview in that order — which is the whole * component, and the reason it usually needs no children. */ children?: ReactNode; /** Forwarded to the field when the editor draws its own. */ placeholder?: string; /** Height of the field, in lines. Forwarded to the field the editor draws. */ rows?: number; /** * Lift the editor above the keyboard. Forwarded to the field the editor * draws, and off by default because it changes which component renders the * container — so it cannot be toggled at runtime without remounting the * field and dropping focus. */ avoidKeyboard?: boolean; /** * Continue a list when Return is pressed inside one, and end it when Return * is pressed on an item with nothing in it. On by default: a list that stops * numbering itself after the first item is a list the writer finishes by * hand. */ continueLists?: boolean; } const MarkdownEditorRoot = forwardRef( ( { className, value: valueProp, defaultValue = '', onValueChange, mode: modeProp, defaultMode = 'write', onModeChange, disabled = false, children, placeholder, rows, avoidKeyboard, continueLists = true, ...props }, ref ) => { const slots = markdownEditorVariants(); const viewRef = useRef(null); const fieldRef = useRef(null); const [internalValue, setInternalValue] = useState(defaultValue); const value = valueProp ?? internalValue; const [internalMode, setInternalMode] = useState(defaultMode); const mode = modeProp ?? internalMode; /* * The live selection is a ref rather than state. * * It changes on every caret move — every keystroke, every tap in the text — * and nothing on screen depends on it. Holding it in state would re-render * the field, and therefore the preview under it, once per character typed. */ const selectionRef = useRef({ start: 0, end: 0 }); /* * What the field should show as selected after a toolbar edit, held for * exactly one render. * * A `selection` prop that stays set is a field the writer cannot move the * caret in — every tap would be pulled back to wherever the last edit put * it. So it is set once, applied, and cleared by the very selection change * it causes. */ const [pending, setPending] = useState(undefined); const valueRef = useRef(value); valueRef.current = value; const commit = useCallback( (next: string) => { if (valueProp === undefined) setInternalValue(next); onValueChange?.(next); }, [valueProp, onValueChange] ); const setMode = useCallback( (next: MarkdownEditorMode) => { if (modeProp === undefined) setInternalMode(next); onModeChange?.(next); }, [modeProp, onModeChange] ); /* * Which actions are already in effect, held in state because the toolbar * draws from it. * * Recomputed when the caret moves or the text changes through an edit, not * on every keystroke: eight predicates over the whole document on every * character typed is work nobody asked for, and typing a letter cannot * change what is applied at the caret. Toggling a marker can, which is why * `run` recomputes it too. */ const [active, setActive] = useState>( () => new Set() ); const readActive = useCallback((text: string, selection: EditorSelection) => { const next = new Set(); for (const key of Object.keys(ACTIONS) as MarkdownEditorAction[]) { if (ACTIONS[key].isActive?.(text, selection)) next.add(key); } setActive((current) => current.size === next.size && [...next].every((key) => current.has(key)) ? current : next ); }, []); const run = useCallback( (action: MarkdownEditorAction) => { const result = ACTIONS[action].apply(valueRef.current, selectionRef.current); // Kept locally too: the parent may be slow to hand the new text back, // and the next press must not be computed against the old string. valueRef.current = result.text; selectionRef.current = result.selection; commit(result.text); setPending(result.selection); readActive(result.text, result.selection); }, [commit, readActive] ); /* * A list continues itself when Return lands inside one. * * Read off the text change rather than off `onKeyPress`, because a key * event here cannot be prevented — the field applies the newline whatever * the handler does, so acting on the key would insert the marker *and* a * second line break. Watching the change instead means the newline is * already in the string, and what happens is a rewrite of it: the marker * goes in behind it, or, on an item with nothing in it, the marker comes * off and the list ends. * * One inserted character only. A paste that happens to contain a newline * is not somebody pressing Return. */ const applyChange = useCallback( (next: string) => { const previous = valueRef.current; if (continueLists && next.length === previous.length + 1) { let at = 0; while (at < previous.length && previous[at] === next[at]) at += 1; if (next[at] === '\n') { const result = continueList(previous, { start: at, end: at }); if (result) { valueRef.current = result.text; selectionRef.current = result.selection; commit(result.text); setPending(result.selection); readActive(result.text, result.selection); return; } } } valueRef.current = next; commit(next); }, [continueLists, commit, readActive] ); const registerField = useCallback((field: TextInput | null) => { fieldRef.current = field; }, []); useImperativeHandle( ref, () => ({ get view() { return viewRef.current; }, apply: run, getActive: () => [...active], getSelection: () => ({ ...selectionRef.current }), setSelection: (selection) => { selectionRef.current = selection; setPending(selection); readActive(valueRef.current, selection); }, focus: () => { // The field is unmounted while the preview is up, so focusing it has // to bring it back first — otherwise this is a silent no-op that // looks like a broken ref. setMode('write'); fieldRef.current?.focus(); }, blur: () => fieldRef.current?.blur(), setMode, }), [run, active, readActive, setMode] ); const context = useMemo( () => ({ value, mode, setMode, run, active, pending, setSelection: (selection) => { selectionRef.current = selection; readActive(valueRef.current, selection); }, clearPending: () => setPending(undefined), onChangeText: applyChange, registerField, disabled, }), [value, mode, setMode, run, active, pending, applyChange, readActive, registerField, disabled] ); return ( {children ?? ( <> )} ); } ); MarkdownEditorRoot.displayName = 'MarkdownEditor'; /* -------------------------------------------------------------------------- * * Toolbar * -------------------------------------------------------------------------- */ export interface MarkdownEditorToolbarProps extends Omit { className?: string; /** * How the toolbar is drawn. `bar` is the full-width row, with room on it for * a word count or a submit. `pill` is the floating capsule: icon-only, * grouped by hairlines, with the pane switch as a round button beside it. */ variant?: MarkdownEditorToolbarVariant; /** * Which formatting actions to offer, in the order given. The capsule groups * whatever it is given by family — what the words look like, what is being * put into the document, what shape the block is — and draws a hairline * where the family changes. */ actions?: MarkdownEditorAction[]; /** * Show the write/preview switch. On by default — a preview nobody can reach * is a pane that does not exist. */ showModeSwitch?: boolean; /** * Anything else to put on the row: a word count, a save state, a submit. * The capsule has no room for these, so a toolbar with children wants `bar`. */ children?: ReactNode; } /** * The formatting actions, and the switch between writing and reading. * * The formatting run is hidden rather than disabled while previewing. Its * buttons act on a selection, and in the preview there is no selection for them * to act on — a row of controls that are present but inert is a row that has to * be tried before it can be understood. * * An action already in effect where the caret is draws as pressed. Every button * here is a toggle, and a toggle that looks the same in both states is a toggle * nobody discovers is one. */ const MarkdownEditorToolbar = forwardRef( ( { className, variant = 'bar', actions, showModeSwitch = true, children, ...props }, ref ) => { const { mode, setMode, run, active, disabled } = useMarkdownEditor( 'MarkdownEditor.Toolbar' ); const slots = markdownEditorVariants({ variant }); const pill = variant === 'pill'; const shown = actions ?? (pill ? DEFAULT_PILL_ACTIONS : DEFAULT_ACTIONS); const button = (action: MarkdownEditorAction, size: number) => ( ); if (pill) { return ( {mode === 'write' ? ( {shown.map((action, index) => ( {/* A hairline wherever the family changes, and nowhere else: a divider between every pair is eight dividers, which is a row of eight separate things rather than three groups. */} {index > 0 && FAMILY[action] !== FAMILY[shown[index - 1]!] ? ( ) : null} {button(action, 18)} ))} ) : null} {showModeSwitch ? ( /* Outside the capsule, and round rather than a segment of it. The capsule is what the writing is done with; this is the way out of it, and a control that leaves somewhere should not look like one of the things you do while you are there. */ setMode(mode === 'write' ? 'preview' : 'write')} > {mode === 'write' ? : } ) : null} ); } return ( {mode === 'write' ? ( {shown.map((action) => button(action, 16))} ) : ( )} {children} {showModeSwitch ? ( ) : null} ); } ); MarkdownEditorToolbar.displayName = 'MarkdownEditor.Toolbar'; /* -------------------------------------------------------------------------- * * Input * -------------------------------------------------------------------------- */ export interface MarkdownEditorInputProps extends Omit { className?: string; } /** * The field the markdown is written in — a `Textarea`, tracking its selection. * * Renders nothing while the preview is up. Unmounted rather than hidden: a * field kept alive behind the preview keeps the keyboard up with it. */ const MarkdownEditorInput = forwardRef( ({ className, rows = 8, ...props }, ref) => { const { value, mode, pending, setSelection, clearPending, onChangeText, registerField, disabled, } = useMarkdownEditor('MarkdownEditor.Input'); /* * The field is registered so the editor's own ref can focus it, and * forwarded so a caller's ref still works. Both, rather than either. */ const attach = useCallback( (field: TextInput | null) => { registerField(field); if (typeof ref === 'function') ref(field); else if (ref) ref.current = field; }, [ref, registerField] ); /* * The forced selection is cleared only once it has actually arrived. * * Clearing on the first selection change of any kind was the bug: the new * `value` landing produces one of its own, and it fires first — so the * caret was released before it had been put anywhere, and ended up at the * end of the document instead of between the markers just inserted. */ const handleSelectionChange = useCallback( (event: NativeSyntheticEvent) => { const { selection } = event.nativeEvent; setSelection(selection); if ( pending && selection.start === pending.start && selection.end === pending.end ) { clearPending(); } }, [setSelection, clearPending, pending] ); if (mode !== 'write') return null; return (