Skip to content

MarkdownRichEditor

317jamtay317 edited this page Jul 26, 2026 · 5 revisions

MarkdownRichEditor

The single-pane WYSIWYG editing surface of LiveMarkDownEditor. A custom control deriving from RichTextBox that shows a Markdown Document as a formatted Visual Document — a heading looks like a heading, bold is bold — so the user edits content without ever seeing raw Markdown syntax, while the canonical Markdown source is exposed for binding.

  • Class: UI.Controls.MarkdownRichEditor
  • Default style: src/UI/Controls/MarkdownRichEditor.xaml (merged in App.xaml)
  • Base: System.Windows.Controls.RichTextBox

This is the one place in the UI where interaction logic lives outside a ViewModel, per the project's Control exception to the zero-code-behind rule.

Purpose

Keep two representations of the same content in sync:

  • Project — assigning Markdown parses it (GFM) and builds the Visual Document shown to the user.
  • Capture — when the user edits the Visual Document, the control serialises it back to canonical Markdown and pushes it to Markdown.

The heavy lifting lives in UI.Wysiwyg.MarkdownToFlowDocumentProjector and UI.Wysiwyg.FlowDocumentToMarkdownCapturer; the control orchestrates them and guards against the two directions echoing each other.

Properties

Property Type Default Description
Markdown string "" The canonical Markdown source text. Binds two-way by default. Setting it Projects a new Visual Document; editing the surface Captures back into it.
IsCaretInTable bool false Whether the caret sits inside a Table — the availability switch for the Table Formatting Actions (Insert Table only outside, Add Row / Add Column only inside). Remove Row and Remove Column narrow this further: they are gated on TableEditing.CanRemoveRow / CanRemoveColumn, which also exclude the header row and the last column (INV-019).
FindQuery string "" The Find query. Every occurrence in the Visual Document is highlighted as a Match.
IsFindActive bool false Whether the Find Bar is open. Setting it false clears the Find highlights.
Replacement string "" The text a Match is swapped for, inserted verbatim (INV-022).
IsReplaceActive bool false Whether the Find Bar's Replace Row is shown. Ctrl+H opens the Find Bar with it; Ctrl+F without.
MatchCount int 0 Read-only. The number of Matches for the current FindQuery.
MatchSummary string "" Read-only. The Find Bar's summary: empty with no query, "No results", or "{ordinal} of {count}".
SyntaxHighlighter ISyntaxHighlighter? null The tokenizer each Code Block's Syntax Highlighting is colored by (INV-064). Left unbound, Code Blocks show their code plain.

Formatting Actions (Toggle Code, Lists & Tables)

Formatting Actions are real edits: they change the Visual Document, which Captures back into Markdown like any other edit, always to canonical Markdown (INV-018). They are driven through UI.Controls.MarkdownEditingCommands routed commands (wire a button or menu item with CommandTarget aimed at the editor), or called directly:

Member Command Description
ToggleCodeAtSelection() ToggleCode A selection within a single line becomes a Code Span; a selection spanning multiple whole top-level paragraphs, or one whole top-level paragraph, becomes a Code Block. Within a List, Block Quote, or Table it is always a Code Span — one per line touched — since a fence can only replace a top-level block (INV-018). The fence spans exactly the paragraphs the selection touches, so Select All fences the whole document and each line keeps its indentation verbatim. Inside existing code the code formatting is removed. Enabled when text is selected or the caret is in code.
SetHeadingLevelAtCaret(int level) SetHeadingLevel Makes the block at the caret a Heading of level (1–6), or — given MarkdownEditingCommands.ParagraphHeadingLevel (0) — a plain paragraph again. It sets rather than toggles, so only 0 clears a Heading (INV-027). The control registers Ctrl+0–Ctrl+6 for these itself: the level rides on each KeyBinding's CommandParameter, because a RoutedUICommand's own KeyGesture carries no parameter.
InsertTableAtCaret() InsertTable Inserts a new Table — three columns, a header row, two empty body rows — at the caret and selects the first header cell. Enabled only while the caret is not in a Table.
AddTableRowAtCaret() AddTableRow Inserts a new empty row below the caret's row, at the Table's column count (INV-019). Enabled only while the caret is in a Table.
AddTableColumnAtCaret() AddTableColumn Inserts a new empty column right of the caret's column, extending every row (INV-019). Enabled only while the caret is in a Table.
RemoveTableRowAtCaret() RemoveTableRow Deletes the caret's row from its Table (INV-019). Enabled only while the caret is in a Table and not in its header row — a pipe table is nothing without its header.
RemoveTableColumnAtCaret() RemoveTableColumn Deletes the caret's column from its Table, shrinking every row and dropping that column's alignment (INV-019). Enabled only while the caret is in a Table with more than one column.
ToggleUnorderedListAtSelection() ToggleUnorderedList The selected paragraphs become an Unordered List; an Unordered List becomes plain paragraphs again; an Ordered List is converted rather than removed (INV-023).
ToggleOrderedListAtSelection() ToggleOrderedList The counterpart of Toggle Unordered List, for Ordered Lists (INV-023).
ToggleTaskListAtSelection() ToggleTaskList Gives every selected List Item lacking one an unchecked Task Marker, or clears them all when every selected List Item already carries one. Outside a List it makes the selected paragraphs an Unordered List first, since a Task Marker exists only on a List Item. An Unordered Task List shows no bullet — the checkbox is the item's marker (INV-023).

The formatting logic lives in UI.Wysiwyg.CodeFormatting, UI.Wysiwyg.TableEditing, and UI.Wysiwyg.ListFormatting, which the Projector shares, so Capture treats user-applied code, Tables, and Lists exactly like ones loaded from Markdown.

Toggle Task Marker (clicking a checkbox)

Clicking a Task Marker's checkbox toggles it between [ ] and [x]. This is a real edit, but it is not a Formatting Action: it reaches the document by direct manipulation rather than through the command bar or a key gesture, so it has no routed command (INV-024).

Member Description
ToggleTaskMarkerAt(TextPointer? position) Flips the Task Marker at position, changing nothing else. Returns false — and makes no edit — when the position is not on a Task Marker, so the click places the caret as usual. Called by the control's OnPreviewMouseLeftButtonDown.
ContinueTaskListAtCaret() Breaks the line in a task item and gives the new List Item its own unchecked Task Marker, the way a bullet or a number carries to the next item (INV-023). Returns false outside a task item, so Enter behaves as usual. Called by the control's OnPreviewKeyDown.
MarkContinuedTaskItemAtCaret() The rule behind the above, on its own: marks the List Item at the caret when the item before it is a task item and it is not. Split out because the paragraph break runs through a WPF editing command that needs a focused editor, so only this half is testable headless.

UI.Wysiwyg.TaskMarkerEditing is the one definition of a Task Marker: it composes the marker (glyph plus role) for the Projector, ListFormatting, and this toggle alike, and updates the glyph and the role together so what the user sees and what Capture emits can never disagree. The marker owns the single space separating its checkbox from the item's text — the Projector strips the one the Markdown source carries on the following text, and Capture re-emits it as "[ ] ".

Folding (collapsible Sections)

The editor can Fold a Section — hide a heading's Section Body up to the next heading of equal or higher level, the way Visual Studio collapses a region. Folding is view-only: Folded bodies are retained and Captured in place, so a Fold never changes Markdown (INV-011). Section boundaries are computed by the pure UI.Wysiwyg.SectionMap.

Member Description
Fold(Block heading) Folds the Section led by heading. Throws ArgumentException if the block is not a Section Heading.
Unfold(Block heading) Restores the Section's Section Body.
ToggleFold(Block heading) Folds if Unfolded, Unfolds if Folded.
ToggleFoldAtCaret() Toggles the Fold of the Section containing the caret.
CollapseAllFolds() Folds every Section, collapsing the document to its top-level Section Headings.
ExpandAllFolds() Unfolds every Folded Section.
IsFolded(Block heading) Whether the Section is currently Folded.
IsSectionHeading(Block block) Whether the block is a Section Heading (a foldable heading). Used by the Editor Gutter to place a Fold Toggle.
Capture() Captures the full logical document (visible blocks with Folded bodies spliced back in) to canonical Markdown.

Folds are driven from the UI through the UI.Controls.MarkdownEditingCommands routed commands (ToggleFold — Ctrl+M; CollapseAllFolds; ExpandAllFolds — Ctrl+Shift+M), which the control handles via command bindings, and through the per-heading Fold Toggle chevrons in the Editor Gutter. Folds are presentation state and are cleared whenever Markdown is re-Projected.

Find & Replace

Find locates every occurrence of FindQuery in the Visual Document and highlights them through the Find Highlight Adorner. Find is view-only: it highlights, scrolls, and selects, but never changes Markdown (INV-016). The scan is the pure UI.Find.MatchScanner, which snapshots the document's text, delegates the search to UI.Find.MatchFinder, and maps the Matches back to ranges — so a Match may span an inline formatting boundary but never bridges two blocks.

Replace is the part that edits: it swaps a Match for the Replacement and Captures the result back into Markdown like any other edit (INV-022).

Member Command Description
ShowFind Opens the Find Bar and focuses the query box (Ctrl+F). Leaves the Replace Row hidden.
ShowReplace Opens the Find Bar with the Replace Row (Ctrl+H).
HideFind Closes the Find Bar and its Replace Row, clearing the highlights (Escape).
FindNext / FindPrevious Move the Current Match, wrapping around the ends (F3 / Shift+F3). Enabled while there are Matches.
ReplaceCurrentMatch() Replace Swaps the Current Match for the Replacement, then moves to the next Match. Enabled while there are Matches — it acts on the Current Match, so it needs one.
ReplaceAllMatches() ReplaceAll Swaps every Match for the Replacement in one undoable edit. Enabled whenever there is a query — deliberately not gated on MatchCount, because the occurrences it exists to catch may all be hidden inside Folded Sections, leaving the count at zero.

Four behaviours are worth knowing, all pinned by MarkdownRichEditorReplaceTests:

  • A Replacement is verbatim. A Match is found case-insensitively, but the Replacement is never re-cased to suit it. An empty Replacement deletes the Match — the way to delete every occurrence.
  • Formatting is inherited only when the Match has one. Replacing a word inside bold text leaves it bold; a Match spanning a formatting boundary (**bo**ld) has no single formatting to inherit, so its Replacement is plain. This holds for bold, italic, code, and strikethrough alike. MatchReplacer gets there by keeping the Run: a Match lying wholly inside one Run has the Replacement inserted into that Run before the Match is deleted, so the Run is never empty and the Bold/Italic/Code Span element above it survives untouched. Assigning TextRange.Text alone would not do, because deleting first loses the formatting two ways — a Match that is the whole Run (plain **bolld** text, a single bolded word) empties it, and WPF removes an emptied Run along with the element above it; a Match merely starting the Run (plain **bolld here**) leaves the insertion point on the Run's boundary, where WPF takes the formatting from the preceding content. Either way the Replacement came out plain. A Match that straddles a boundary lies in no single Run, falls through to TextRange.Text, and flattens — which is the rule above.
  • Replace All Unfolds first. Find searches only the visible document, so an occurrence inside a Folded Section Body is invisible to it while still being present in Markdown. Replace All calls ExpandAllFolds() and re-finds before replacing, so "All" means the whole Markdown Document.
  • Replace All is one edit. It replaces a snapshot of the ranges taken before the first edit — so a Replacement containing the query cannot cascade — wrapped in BeginChange()/EndChange(), which makes the batch a single undo unit. WPF attaches no undo stack until the control is loaded in a visual tree, so the undo grouping is verified by driving Ctrl+Z in the running app rather than by a headless test.

Syntax Highlighting (coloring code by what it means)

A Code Block whose fence names a language it can tokenize is colored by what its code means — comments muted, strings and numbers set apart, keywords emphasized (INV-064). It is the Code Block's alone: a Code Span carries no language, so nothing says what its code would mean.

  • The colors live on the Runs, not in an overlay. Code Shading is an adorner because a shade is something drawn behind text. Coloring is not: an overlay that owns no text cannot recolor glyphs the editor has already drawn. So the block's inline content is rebuilt as one Run per Code Token.
  • A theme flip is still free. Each Run's Foreground is a SetResourceReference to its kind's palette brush (CodeTokenKeywordBrush, …), never a fixed color — so switching theme recolors every Code Block without re-tokenizing and without re-projecting the document.
  • Typing re-colors the block, once the typing settles. An edit inside a Code Block queues that block on a 200 ms timer; the tick re-tokenizes that one block, restores the caret to the same place in the code, and runs inside MutateVisualDocument so it raises no Capture.
  • A no-op re-highlight touches nothing. Before rebuilding, the new Code Tokens are compared against what the paragraph already shows (each Run records its kind on its Tag). If they match, the paragraph is left entirely alone — which is what stops the caret and the undo stack churning.
  • Undo needs two guards, and both are load-bearing. The rebuild is wrapped in BeginChange/EndChange, and a re-highlight is skipped when TextChangedEventArgs.UndoAction reports an undo or a redo. Without the first, the empty-then-refill lands as two undo entries and one Ctrl+Z leaves the Code Block empty. Without the second, undoing a re-highlight schedules another one whose entry the next Ctrl+Z takes back instead — the user can never reach their typing. IsUndoEnabled = false is not a third option: WPF empties the undo stack when it is set false. The residual cost is one extra Ctrl+Z on a re-colored block (first the coloring, then the typing).
  • Never an edit. The rebuilt Runs concatenate to exactly the code that was there — the tokenizer guarantees it — and Capture reads a Code Block's Runs for their text alone, so the Markdown Document is untouched.
  • Print gets fixed ink. Print and Print Preview compose a fresh Visual Document that is never shown in the window, and paper is white whatever theme the app is in — so they call SyntaxHighlighting.ApplyAllForPrint, which writes the light palette's colors outright instead of referencing a brush.
  • Unknown languages stay plain. No fence info string, or one no grammar claims (bash, yaml, go), means no coloring at all — the language is never guessed from the code's contents. A mermaid block is shown as a rendered picture (INV-047), so there is no code text to color.

Outline & Navigation

The editor exposes its Outline — every Section Heading, in document order — so the Navigation Panel can list them and jump between them. The Outline lists all Section Headings, including ones inside a Folded Section Body, so it always mirrors the whole document. Reading the Outline and Navigating are view-only: neither changes Markdown (INV-012).

Member Description
Outline IReadOnlyList<SectionHeading> — every Section Heading (level + text) in document order, Folded ones included. Each SectionHeading is an Outline Entry.
Navigate(SectionHeading heading) Reveals the heading (Unfolding its enclosing Section if hidden), selects it, and scrolls it into view.
CurrentSection The SectionHeading whose Section most immediately encloses the caret, or null.
OutlineChanged (event) Raised when the Outline may have changed (re-Projection or a structural edit).
CurrentSectionChanged (event) Raised when the Current Section may have changed (caret move or re-Projection).

Events

Inherits RichTextBox events, plus OutlineChanged and CurrentSectionChanged (above). The control overrides OnTextChanged internally to drive Capture; consumers bind to Markdown rather than handling text-changed directly.

Behaviour notes

  • Formatting is detected by effective run properties, so both formatting loaded from Markdown and formatting applied via the toolbar (EditingCommands.ToggleBold / ToggleItalic, which set FontWeight / FontStyle) round-trip to ** / *.
  • Re-entrancy guard: an internal flag plus a "last captured" comparison stop a Capture-driven update to Markdown from re-Projecting (which would reset the caret).
  • Live external updates: when the bound Editor Session replaces Markdown (e.g. the Watched File changed on disk), the Visual Document is re-Projected to match.
  • Page View: by default the Visual Document is laid out on a Document Sheet of whole 8.5 × 11 Pages floating on a canvas (like a word processor), confining every element — tables included — to one page width and growing a Page as soon as the content needs it. This is the PageView behaviour, not this control: the editor exposes only a small RevealRectOverride seam so find-match and heading jumps scroll the canvas in Page View, and in Page View it goes transparent so the DocumentSheetBackdrop can draw the paper and the Page Breaks behind the text (INV-058).

Usage

<controls:MarkdownRichEditor
    Markdown="{Binding Markdown, UpdateSourceTrigger=PropertyChanged}" />

Bind Markdown to the Editor Session's canonical source text. Add formatting buttons that target the editor by name:

<Button Content="B" Command="EditingCommands.ToggleBold"
        CommandTarget="{Binding ElementName=Editor}" />

Supported Markdown constructs

Headings, paragraphs, bold, italic, strikethrough, Code Spans, fenced and indented Code Blocks, Unordered and Ordered Lists (with nesting), task-list items, Links, autolinks, Images, Block Quotes, Thematic Breaks, GFM Tables (with column alignment), and hard line breaks all round-trip — see docs/Invariants.md (INV-004) for the authoritative list.

Clone this wiki locally