# 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](EditorGutter.md). 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](FindHighlightAdorner.md). 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](OutlinePanel.md) 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` — 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](PageView.md) 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](DocumentSheetBackdrop.md) can draw the paper and the Page Breaks behind the text (INV-058). ## Usage ```xml ``` Bind `Markdown` to the Editor Session's canonical source text. Add formatting buttons that target the editor by name: ```xml