-
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}". |
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. -
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.
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 fixed-width Document Sheet floating on
a canvas (like a word processor), confining every element — tables included — to one page width. 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 (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