-
Notifications
You must be signed in to change notification settings - Fork 0
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 inApp.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.
Keep two representations of the same content in sync:
-
Project — assigning
Markdownparses 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.
| 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 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.
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 "[ ] ".
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 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.MatchReplacergets there by keeping the Run: a Match lying wholly inside oneRunhas the Replacement inserted into that Run before the Match is deleted, so the Run is never empty and theBold/Italic/Code Span element above it survives untouched. AssigningTextRange.Textalone 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 toTextRange.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 callsExpandAllFolds()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.
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
Runper Code Token. -
A theme flip is still free. Each Run's
Foregroundis aSetResourceReferenceto 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
MutateVisualDocumentso 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 whenTextChangedEventArgs.UndoActionreports 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 = falseis 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. Amermaidblock is shown as a rendered picture (INV-047), so there is no code text to color.
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). |
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.
-
Formatting is detected by effective run properties, so both formatting loaded from Markdown
and formatting applied via the toolbar (
EditingCommands.ToggleBold/ToggleItalic, which setFontWeight/FontStyle) round-trip to**/*. -
Re-entrancy guard: an internal flag plus a "last captured" comparison stop a Capture-driven
update to
Markdownfrom 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
RevealRectOverrideseam 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).
<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}" />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.
LiveMarkDownEditor
User guide
- Getting Started
- Writing and Formatting
- Tables
- Diagrams
- Files, Folders and Tabs
- Finding and Navigating
- Panels and Layout
- Live Updates and Conflicts
- Pages, Printing and Export
- Spell Check
- Keyboard Shortcuts
Controls
- MarkdownRichEditor
- PageView
- DocumentSheetBackdrop
- PrintPreviewPages
- EditorGutter
- SourceGutter
- OutlinePanel
- FolderPanel
- PanelHeader
- PanelColumn
- CommandBarPanel
- CommandTip
- FindHighlightAdorner
- SpellCheckAdorner
- CodeShadingAdorner
- ChangeHighlightAdorner
- MermaidPreview
- MermaidDiagramView
- DiagramCanvas
- VideoPlayerView