diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c1ff9f..f6daad8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,8 +19,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Find and replace on `MintedEditorView`**: `FindNext`, `FindPrevious`, `Replace` and `ReplaceAll` select and scroll to matches (wrapping by default, with match-case and whole-word options), and a `FindReplaceCompleted` event reports the match count and current match. Each `Replace`/`ReplaceAll` call is a single undo step and keeps the match's formatting. The logic lives in the new Core class `EditorFindReplace`. There is no built-in find UI.
### Changed
-- `ISpellCheckProvider` (`MTE001`), `IPrintProvider` (`MTE002`) and `ITextShaper` / `ShapedTextResult` / `DefaultTextShaper` (`MTE003`) are now marked `[Experimental]`: the editor does not use them yet, so code that references them now reports the diagnostic and must suppress it. See [Experimental APIs](docs/architecture.md#experimental-apis).
- The package version is set once in `Directory.Build.props` instead of `1.0.0` in each project, so local and CI pack runs produce the current version rather than 1.0.0.
+- `ISpellCheckProvider` (`MTE001`), `IPrintProvider` (`MTE002`) and `ITextShaper` / `ShapedTextResult` / `DefaultTextShaper` (`MTE003`) are now marked `[Experimental]`: the editor does not use them yet, so code that references them now reports the diagnostic and must suppress it. See [Experimental APIs](docs/architecture.md#experimental-apis).
- **Theme properties now take effect**: `SelectionTextColor` recolors selected text, `HyperlinkHoverColor` recolors the link under the mouse pointer, `ToolbarHoverColor` fills the hovered toolbar button or drop-down, `FocusRingColor` draws the focus ring, and `LineSpacing` / `ParagraphSpacing` set the spacing of paragraphs that don't set their own `ParagraphStyle.LineSpacing` / `SpaceAfter`. Hover needs a platform that reports mouse movement (Windows).
- The built-in themes' `LineSpacing` is now 0 (was 2, unused), and `EditorStyle.ParagraphSpacing` defaults to 8, so existing documents lay out exactly as before.
- Default English labels of built-in toolbar and context menu items now match the `EditorStrings` defaults (sentence case, e.g. "Font family", "Select all", "Numbered list", "Edit hyperlink…"), and the heading dropdown lists "Normal text" and "Heading 1"–"Heading 6" instead of "Normal" and "H1"–"H6". Code that finds built-in items by `Label` should match on `Icon` instead.
@@ -29,6 +29,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `SkiaSharpEngine`, a placeholder class whose only member, `IsAvailable`, always returned `true`.
### Fixed
+- **Undo for character formatting**: bold, italic, underline, strikethrough, subscript, superscript and clear formatting (toolbar, Ctrl+B/I/U and commands), the font family and size dropdowns, and the text and highlight colour pickers now record one undo step each. Undo restores the previous runs with their exact styles and redo reapplies the change. Toggling with a collapsed caret still only sets the pending style and is not recorded. `ApplyStyleAction` gains a constructor that wraps any formatting operation, and its undo no longer resets the paragraph style or restores the wrong paragraph when a table lies inside the range.
+- **Removing a hyperlink**: `HyperlinkEngine.RemoveHyperlink` keeps the link text's formatting instead of turning it into one plain run, and `RemoveHyperlinkCommand` (toolbar and context menu) is now undoable through the new `RemoveHyperlinkAction`.
+- **Undo restoring copies**: undoing a delete, paragraph merge, formatting change or link removal puts back the original runs, images, links and line breaks instead of copies, so undoing an earlier image or link insertion afterwards still removes it. Line breaks no longer come back as `"\n"` text, and images and line breaks inside links are no longer dropped.
- **`InsertHyperlinkCommand.DisplayText`** is now used: when set, the link is inserted with that text at the caret, or replaces the selected text. Undoing a hyperlink insertion now restores the original text with its formatting (previously it came back unformatted), and redoing a link made from a selection no longer throws. A link inserted with the caret right after an image or another link now goes after it rather than before. Undoing a deletion or a link insertion keeps soft line breaks, images and links (with all their content) as the same objects instead of converting them to text or copies, so earlier undo steps such as an image insertion still work, and undoing one that spanned paragraphs refreshes the layout of every paragraph after them.
- Changing the theme's `HyperlinkColor` now recolors links already laid out, instead of only links in paragraphs edited afterwards.
- When the document gets shorter (e.g. smaller theme spacing), the editor no longer stays scrolled past its end.
diff --git a/docs/architecture.md b/docs/architecture.md
index bb52632..6c12616 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -80,7 +80,7 @@ MintedTextEditor is structured in three layers, each with a clear responsibility
The core layer is **fully platform-independent** — it targets `net10.0` only and has no dependency on MAUI, SkiaSharp, or any platform SDK. This makes it testable with a mock `IDrawingContext` in a plain xUnit project (`tests/MintedTextEditor.Core.Tests`). The toolbar icon SVGs (Lucide, Heroicons, Material Symbols) are embedded resources in this assembly.
-**Document Model**: `TextStyle` is an immutable class whose `With…` methods return new instances; `ParagraphStyle` is a mutable class. Undo works by recording actions that know how to reverse themselves; the formatting actions (`ApplyStyleAction`, `ApplyParagraphStyleAction`) do so by snapshotting the affected paragraphs before the change.
+**Document Model**: `TextStyle` is an immutable class whose `With…` methods return new instances; `ParagraphStyle` is a mutable class. Undo works by recording actions that know how to reverse themselves; actions that restyle or delete text (`ApplyStyleAction`, `ApplyParagraphStyleAction`, `DeleteRangeAction`, …) snapshot the paragraphs they touch before the change and restore them on undo.
**TextLayoutEngine** converts a `Document` into a `DocumentLayout` of `LayoutBlock`s (or `TableLayoutBlock`s), each with `LayoutLine`s made of positioned `LayoutRun`s. Wrapping never splits a grapheme cluster (emoji ZWJ sequences, flags, combining marks), and very long unbroken text wraps in linear time. `LayoutCache` keeps the layout per block and re-lays out only the blocks a change touches. `DocumentHitTester` maps a point to a `DocumentPosition`.
diff --git a/docs/formatting.md b/docs/formatting.md
index 2aaca76..0b98156 100644
--- a/docs/formatting.md
+++ b/docs/formatting.md
@@ -53,7 +53,7 @@ font.RemoveHighlightColor(doc, range);
### Programmatic (DocumentEditor + UndoManager)
-The engines above change the document directly and are not recorded for undo. For undo-aware programmatic formatting, push an `ApplyStyleAction`; `UndoManager.Push` executes it and records it:
+The engines above change the document directly and are not recorded for undo. The toolbar, the keyboard shortcuts and the character-formatting commands (`ToggleBoldCommand` … `ClearFormattingCommand`, `ApplyFontFamilyCommand`, `ApplyFontSizeCommand`, `ApplyTextColorCommand`, `ApplyHighlightColorCommand`) are: each records one undo step. From code, run one of those commands with an `EditorContext` (see [Commands & Events](commands-events.md)), or push an `ApplyStyleAction`; `UndoManager.Push` executes it and records it:
```csharp
using MintedTextEditor.Core.Editing;
@@ -63,8 +63,14 @@ var action = new ApplyStyleAction(doc, range, s => s
.WithFontSize(14f));
editor.UndoManager.Push(action); // executes and records undo
+
+// Or wrap an engine call, e.g. a toggle that decides on/off from the current text
+editor.UndoManager.Push(new ApplyStyleAction(doc, range,
+ r => editor.Formatting.ToggleBold(doc, r), "Bold"));
```
+Undo restores the runs exactly as they were, styles included. Push an `ApplyStyleAction` only for a non-empty range; with a collapsed caret, call the engine directly to set the pending style.
+
### Querying Current Formatting
```csharp
@@ -158,7 +164,7 @@ DocumentEditor.ApplyParagraphStyle(doc, range, s => s.Direction = TextDirection.
## Undo / Redo Integration
-Typing, deleting, pasting, the insert commands (hyperlink, image, table, rows and columns) and the paragraph commands (alignment, lists, indent, headings, paragraph format) are recorded by the `UndoManager`. The formatting engines (`FormattingEngine`, `FontFormattingEngine`, `ParagraphFormattingEngine`) are not; push an `ApplyStyleAction` or `ApplyParagraphStyleAction` (above) when formatting from code must be undoable.
+Typing, deleting, pasting, the insert commands (hyperlink, image, table, rows and columns), removing a hyperlink, the paragraph commands (alignment, lists, indent, headings, paragraph format), and character formatting from the toolbar (buttons, font family and size dropdowns, text and highlight colour pickers), keyboard shortcuts and commands are recorded by the `UndoManager`, one step per action. Toggling a format with a collapsed caret only sets the pending style and is not recorded. The formatting engines (`FormattingEngine`, `FontFormattingEngine`, `ParagraphFormattingEngine`) are not recorded; push an `ApplyStyleAction` or `ApplyParagraphStyleAction` (above) when formatting from code must be undoable.
```csharp
editor.Undo(); // or editor.UndoManager.Undo()
diff --git a/docs/images-hyperlinks.md b/docs/images-hyperlinks.md
index 0d54e9e..224e302 100644
--- a/docs/images-hyperlinks.md
+++ b/docs/images-hyperlinks.md
@@ -138,8 +138,11 @@ if (link is not null)
### Removing a Hyperlink
+The link's text stays in place with its character formatting (bold, colours, …). From code, use the undoable `RemoveHyperlinkCommand`, which acts on the link at the caret, or call the engine directly, without undo:
+
```csharp
-HyperlinkEngine.RemoveHyperlink(editor.Document, editor.Selection.Active); // keeps the text
+new RemoveHyperlinkCommand().Execute(ctx); // undoable
+HyperlinkEngine.RemoveHyperlink(editor.Document, editor.Selection.Active); // not recorded for undo
```
The toolbar's Object menu also has Open Link and Remove Link.
diff --git a/src/MintedTextEditor.Core/Commands/EditorContext.cs b/src/MintedTextEditor.Core/Commands/EditorContext.cs
index 29e8352..ae55bef 100644
--- a/src/MintedTextEditor.Core/Commands/EditorContext.cs
+++ b/src/MintedTextEditor.Core/Commands/EditorContext.cs
@@ -1,3 +1,4 @@
+using MintedTextEditor.Core.Document;
using MintedTextEditor.Core.Editing;
using MintedTextEditor.Core.Formatting;
@@ -43,4 +44,31 @@ public EditorContext(
FontFormattingEngine = fontFormattingEngine;
Clipboard = clipboard;
}
+
+ ///
+ /// Applies a character-formatting operation to the current selection as one undo step,
+ /// keeping the selection on the same text afterwards.
+ ///
+ ///
+ /// When the selection is empty, runs directly and nothing is
+ /// recorded: the formatting engines only update their pending style in that case.
+ /// Otherwise an is pushed to .
+ ///
+ /// Description of the undo step, e.g. "Bold".
+ ///
+ /// Formats the range it is given, e.g. r => ctx.FormattingEngine.ToggleBold(ctx.Document, r).
+ ///
+ public void ApplyCharacterFormatting(string description, Action apply)
+ {
+ var (anchor, active) = DocumentEditor.ApplyCharacterFormatting(
+ Document, Selection.Anchor, Selection.Active,
+ range =>
+ {
+ if (range.IsEmpty)
+ apply(range);
+ else
+ UndoManager.Push(new ApplyStyleAction(Document, range, apply, description));
+ });
+ Selection.Set(anchor, active);
+ }
}
diff --git a/src/MintedTextEditor.Core/Commands/FontCommands.cs b/src/MintedTextEditor.Core/Commands/FontCommands.cs
index 4326beb..53f7f95 100644
--- a/src/MintedTextEditor.Core/Commands/FontCommands.cs
+++ b/src/MintedTextEditor.Core/Commands/FontCommands.cs
@@ -7,7 +7,7 @@ namespace MintedTextEditor.Core.Commands;
// ── Apply Font Family ─────────────────────────────────────────────────────────
/// Applies to the selected text.
-public sealed class ApplyFontFamilyCommand : IEditorCommand
+public sealed class ApplyFontFamilyCommand : ICharacterFormattingCommand
{
///
public string Name => "ApplyFontFamily";
@@ -21,7 +21,8 @@ public sealed class ApplyFontFamilyCommand : IEditorCommand
public void Execute(EditorContext ctx)
{
if (string.IsNullOrEmpty(FontFamily)) return;
- ctx.FontFormattingEngine.ApplyFontFamily(ctx.Document, ctx.Selection.Range, FontFamily);
+ var family = FontFamily;
+ ctx.ApplyCharacterFormatting("Font", r => ctx.FontFormattingEngine.ApplyFontFamily(ctx.Document, r, family));
}
///
@@ -31,7 +32,7 @@ public void Execute(EditorContext ctx)
// ── Apply Font Size ───────────────────────────────────────────────────────────
/// Applies to the selected text.
-public sealed class ApplyFontSizeCommand : IEditorCommand
+public sealed class ApplyFontSizeCommand : ICharacterFormattingCommand
{
///
public string Name => "ApplyFontSize";
@@ -45,7 +46,8 @@ public sealed class ApplyFontSizeCommand : IEditorCommand
public void Execute(EditorContext ctx)
{
if (FontSize <= 0) return;
- ctx.FontFormattingEngine.ApplyFontSize(ctx.Document, ctx.Selection.Range, FontSize);
+ var size = FontSize;
+ ctx.ApplyCharacterFormatting("Font Size", r => ctx.FontFormattingEngine.ApplyFontSize(ctx.Document, r, size));
}
///
@@ -55,7 +57,7 @@ public void Execute(EditorContext ctx)
// ── Apply Text Color ──────────────────────────────────────────────────────────
/// Applies as the text colour of the selected text.
-public sealed class ApplyTextColorCommand : IEditorCommand
+public sealed class ApplyTextColorCommand : ICharacterFormattingCommand
{
///
public string Name => "ApplyTextColor";
@@ -66,8 +68,11 @@ public sealed class ApplyTextColorCommand : IEditorCommand
public EditorColor Color { get; set; } = EditorColor.Black;
///
- public void Execute(EditorContext ctx) =>
- ctx.FontFormattingEngine.ApplyTextColor(ctx.Document, ctx.Selection.Range, Color);
+ public void Execute(EditorContext ctx)
+ {
+ var color = Color;
+ ctx.ApplyCharacterFormatting("Text Color", r => ctx.FontFormattingEngine.ApplyTextColor(ctx.Document, r, color));
+ }
///
public bool CanExecute(EditorContext ctx) => true;
@@ -76,7 +81,7 @@ public void Execute(EditorContext ctx) =>
// ── Apply Highlight Color ─────────────────────────────────────────────────────
/// Applies as the highlight colour of the selected text.
-public sealed class ApplyHighlightColorCommand : IEditorCommand
+public sealed class ApplyHighlightColorCommand : ICharacterFormattingCommand
{
///
public string Name => "ApplyHighlightColor";
@@ -87,8 +92,11 @@ public sealed class ApplyHighlightColorCommand : IEditorCommand
public EditorColor Color { get; set; } = EditorColor.Yellow;
///
- public void Execute(EditorContext ctx) =>
- ctx.FontFormattingEngine.ApplyHighlightColor(ctx.Document, ctx.Selection.Range, Color);
+ public void Execute(EditorContext ctx)
+ {
+ var color = Color;
+ ctx.ApplyCharacterFormatting("Highlight Color", r => ctx.FontFormattingEngine.ApplyHighlightColor(ctx.Document, r, color));
+ }
///
public bool CanExecute(EditorContext ctx) => true;
diff --git a/src/MintedTextEditor.Core/Commands/FormattingCommands.cs b/src/MintedTextEditor.Core/Commands/FormattingCommands.cs
index 2d8fe1e..5aeb2ac 100644
--- a/src/MintedTextEditor.Core/Commands/FormattingCommands.cs
+++ b/src/MintedTextEditor.Core/Commands/FormattingCommands.cs
@@ -15,7 +15,7 @@ public sealed class ToggleBoldCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ToggleBold(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Bold", r => ctx.FormattingEngine.ToggleBold(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
@@ -33,7 +33,7 @@ public sealed class ToggleItalicCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ToggleItalic(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Italic", r => ctx.FormattingEngine.ToggleItalic(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
@@ -51,7 +51,7 @@ public sealed class ToggleUnderlineCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ToggleUnderline(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Underline", r => ctx.FormattingEngine.ToggleUnderline(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
@@ -69,7 +69,7 @@ public sealed class ToggleStrikethroughCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ToggleStrikethrough(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Strikethrough", r => ctx.FormattingEngine.ToggleStrikethrough(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
@@ -87,7 +87,7 @@ public sealed class ToggleSubscriptCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ToggleSubscript(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Subscript", r => ctx.FormattingEngine.ToggleSubscript(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
@@ -105,7 +105,7 @@ public sealed class ToggleSuperscriptCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ToggleSuperscript(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Superscript", r => ctx.FormattingEngine.ToggleSuperscript(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
@@ -123,7 +123,7 @@ public sealed class ClearFormattingCommand : ICharacterFormattingCommand
///
public void Execute(EditorContext ctx) =>
- ctx.FormattingEngine.ClearFormatting(ctx.Document, ctx.Selection.Range);
+ ctx.ApplyCharacterFormatting("Clear Formatting", r => ctx.FormattingEngine.ClearFormatting(ctx.Document, r));
///
public bool CanExecute(EditorContext ctx) => true;
diff --git a/src/MintedTextEditor.Core/Commands/HyperlinkCommands.cs b/src/MintedTextEditor.Core/Commands/HyperlinkCommands.cs
index b340394..9f41fba 100644
--- a/src/MintedTextEditor.Core/Commands/HyperlinkCommands.cs
+++ b/src/MintedTextEditor.Core/Commands/HyperlinkCommands.cs
@@ -43,7 +43,10 @@ public void Execute(EditorContext ctx)
// ── Remove Hyperlink ──────────────────────────────────────────────────────────
-/// Removes the hyperlink at the caret position.
+///
+/// Removes the hyperlink at the caret position, keeping its text and formatting.
+/// Recorded as one undo step.
+///
public sealed class RemoveHyperlinkCommand : IEditorCommand
{
///
@@ -52,8 +55,12 @@ public sealed class RemoveHyperlinkCommand : IEditorCommand
public string Description => "Removes the hyperlink at the current caret position.";
///
- public void Execute(EditorContext ctx) =>
- HyperlinkEngine.RemoveHyperlink(ctx.Document, ctx.Selection.Active);
+ public void Execute(EditorContext ctx)
+ {
+ if (HyperlinkEngine.GetHyperlinkAtPosition(ctx.Document, ctx.Selection.Active) is null) return;
+ var pos = ctx.UndoManager.Push(new RemoveHyperlinkAction(ctx.Document, ctx.Selection.Active));
+ ctx.Selection.CollapseTo(pos);
+ }
///
public bool CanExecute(EditorContext ctx) =>
diff --git a/src/MintedTextEditor.Core/Editing/UndoableActions.cs b/src/MintedTextEditor.Core/Editing/UndoableActions.cs
index aa1756e..0281d6f 100644
--- a/src/MintedTextEditor.Core/Editing/UndoableActions.cs
+++ b/src/MintedTextEditor.Core/Editing/UndoableActions.cs
@@ -393,30 +393,55 @@ public DocumentPosition Undo()
// ── ApplyStyleAction ────────────────────────────────────────────────────────
///
-/// Records a style change. Snapshots all affected paragraph inlines before applying
-/// so the originals can be restored on undo.
+/// Records a character-formatting change over a range. Snapshots the inlines of every
+/// affected paragraph before applying, so undo restores the original runs with their exact
+/// styles. Redo applies the change again to the restored runs.
///
+///
+/// Undo restores only the paragraphs' inlines and leaves their
+/// alone, since character formatting never changes it. Runs split by the change are merged
+/// back with same-styled neighbours after it is applied.
+///
public sealed class ApplyStyleAction : IUndoableAction
{
private readonly Document.Document _document;
private TextRange _range;
private readonly ParagraphAnchor _startAnchor;
private readonly ParagraphAnchor _endAnchor;
- private readonly Func _transform;
- private List _snapshots = [];
+ private readonly Action _apply;
+ private readonly List<(int BlockIndex, ParagraphSnapshot Snapshot)> _snapshots = [];
/// Creates an action that applies to the style of the text in .
public ApplyStyleAction(Document.Document document, TextRange range, Func transform)
+ : this(document, range, r => DocumentEditor.ApplyTextStyle(document, r, transform), "Apply Style")
+ {
+ }
+
+ ///
+ /// Creates an action that runs over . Use it for
+ /// formatting operations that choose their own transform, such as the
+ /// toggles, which add a format unless the whole range already has it.
+ ///
+ /// The document to format.
+ /// The range to format.
+ ///
+ /// Formats the range it is given. It may split and restyle the text runs of the paragraphs the
+ /// range covers, but must not add, remove or move text or blocks. It runs again on redo, against
+ /// the same runs it saw the first time.
+ ///
+ /// Description of the undo step, e.g. "Bold".
+ public ApplyStyleAction(Document.Document document, TextRange range, Action apply, string description)
{
_document = document;
_range = range;
_startAnchor = new ParagraphAnchor(document, range.Start);
_endAnchor = new ParagraphAnchor(document, range.End);
- _transform = transform;
+ _apply = apply;
+ Description = description;
}
///
- public string Description => "Apply Style";
+ public string Description { get; }
///
public DocumentPosition Execute()
@@ -426,19 +451,40 @@ public DocumentPosition Execute()
for (int i = _range.Start.BlockIndex; i <= _range.End.BlockIndex && i < _document.Blocks.Count; i++)
{
if (_document.Blocks[i] is Paragraph p)
- _snapshots.Add(SnapshotHelper.Capture(p));
+ _snapshots.Add((i, SnapshotHelper.Capture(p)));
}
- DocumentEditor.ApplyTextStyle(_document, _range, _transform);
- return _range.Start;
+
+ _apply(_range);
+
+ foreach (var (blockIndex, _) in _snapshots)
+ {
+ if (blockIndex < _document.Blocks.Count && _document.Blocks[blockIndex] is Paragraph p)
+ DocumentEditor.NormalizeInlines(p);
+ }
+ return _endAnchor.Resolve(_document);
}
///
public DocumentPosition Undo()
{
- // Each snapshot restores its own paragraph, so a table inside the range cannot shift them.
- SnapshotHelper.RestoreBlocks(_document, _range.Start.BlockIndex, _snapshots);
+ foreach (var (blockIndex, snapshot) in _snapshots)
+ {
+ if (blockIndex < _document.Blocks.Count && _document.Blocks[blockIndex] is Paragraph p)
+ RestoreInlines(p, snapshot);
+ }
_document.NotifyChanged(new DocumentChangedEventArgs(DocumentChangeType.StyleChanged, _range));
- return _range.Start;
+ return _endAnchor.Resolve(_document);
+ }
+
+ ///
+ /// Restores the inlines recorded in but keeps 's
+ /// current paragraph style, which inline-only changes never alter.
+ ///
+ internal static void RestoreInlines(Paragraph p, ParagraphSnapshot snapshot)
+ {
+ var style = p.Style;
+ SnapshotHelper.Restore(p, snapshot);
+ p.Style = style;
}
///
@@ -894,6 +940,65 @@ public DocumentPosition Undo()
public bool MergeWith(IUndoableAction next) => false;
}
+// ── RemoveHyperlinkAction ───────────────────────────────────────────────────
+
+///
+/// Records the removal of a hyperlink, whose text stays in place with its formatting.
+/// Snapshots the paragraph's inlines so undo restores the link exactly.
+///
+public sealed class RemoveHyperlinkAction : IUndoableAction
+{
+ private readonly Document.Document _document;
+ private readonly DocumentPosition _position;
+ private readonly ParagraphAnchor _anchor;
+ private ParagraphSnapshot? _snapshot;
+
+ /// Creates an action that removes the hyperlink at .
+ public RemoveHyperlinkAction(Document.Document document, DocumentPosition position)
+ {
+ _document = document;
+ _position = position;
+ _anchor = new ParagraphAnchor(document, position);
+ }
+
+ ///
+ public string Description => "Remove Hyperlink";
+
+ ///
+ public DocumentPosition Execute()
+ {
+ _snapshot = null;
+ if (HyperlinkEngine.GetHyperlinkAtPosition(_document, _position) is not null &&
+ _document.Blocks[_position.BlockIndex] is Paragraph para)
+ {
+ _snapshot = SnapshotHelper.Capture(para);
+ HyperlinkEngine.RemoveHyperlink(_document, _position);
+ }
+ return _anchor.Resolve(_document);
+ }
+
+ ///
+ public DocumentPosition Undo()
+ {
+ if (_snapshot is not null &&
+ _position.BlockIndex < _document.Blocks.Count &&
+ _document.Blocks[_position.BlockIndex] is Paragraph para)
+ {
+ ApplyStyleAction.RestoreInlines(para, _snapshot);
+ _document.NotifyChanged(new DocumentChangedEventArgs(
+ DocumentChangeType.TextInserted,
+ new TextRange(_position, _position)));
+ }
+ return _anchor.Resolve(_document);
+ }
+
+ ///
+ public DocumentPosition Redo() => Execute();
+
+ ///
+ public bool MergeWith(IUndoableAction next) => false;
+}
+
// ── InsertTableAction ─────────────────────────────────────────────────────
///
diff --git a/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs b/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs
index c96ae84..4d7bb75 100644
--- a/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs
+++ b/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs
@@ -82,9 +82,9 @@ public static void EditHyperlink(
}
///
- /// Removes the first that starts at
- /// , preserving its plain text as a
- /// in its place.
+ /// Removes the at , leaving
+ /// its text in place as s that keep their character formatting.
+ /// Not recorded for undo; use for that.
///
public static void RemoveHyperlink(Document.Document doc, DocumentPosition position)
{
@@ -95,9 +95,23 @@ public static void RemoveHyperlink(Document.Document doc, DocumentPosition posit
int idx = para.Inlines.IndexOf(hyperlink);
if (idx < 0) return;
- // Replace hyperlink with a plain TextRun containing the same text
- para.Inlines.RemoveAt(idx);
- para.Inlines.Insert(idx, new TextRun(hyperlink.GetText()) { Parent = para });
+ // Replace the hyperlink with copies of its text runs, keeping each run's style
+ para.RemoveInline(idx);
+ int insertAt = idx;
+ foreach (var child in hyperlink.Children)
+ {
+ if (child is TextRun run)
+ {
+ if (run.Length > 0)
+ para.InsertInline(insertAt++, new TextRun(run.Text, run.Style));
+ }
+ else
+ {
+ para.InsertInline(insertAt++, child);
+ }
+ }
+ if (para.Inlines.Count == 0)
+ para.AppendRun(string.Empty);
doc.NotifyChanged(new DocumentChangedEventArgs(
DocumentChangeType.TextDeleted,
diff --git a/src/MintedTextEditor.Core/Input/EditorInputController.cs b/src/MintedTextEditor.Core/Input/EditorInputController.cs
index 89e929e..7316f36 100644
--- a/src/MintedTextEditor.Core/Input/EditorInputController.cs
+++ b/src/MintedTextEditor.Core/Input/EditorInputController.cs
@@ -178,7 +178,7 @@ public bool HandleKeyDown(EditorKeyEventArgs e, Document.Document document, Docu
case EditorKey.B when ctrl:
if (FormattingEngine != null)
{
- ApplyFormattingWithSelectionRestore(document, r => FormattingEngine.ToggleBold(document, r));
+ ApplyFormattingWithSelectionRestore(document, "Bold", r => FormattingEngine.ToggleBold(document, r));
return true;
}
return false;
@@ -186,7 +186,7 @@ public bool HandleKeyDown(EditorKeyEventArgs e, Document.Document document, Docu
case EditorKey.I when ctrl:
if (FormattingEngine != null)
{
- ApplyFormattingWithSelectionRestore(document, r => FormattingEngine.ToggleItalic(document, r));
+ ApplyFormattingWithSelectionRestore(document, "Italic", r => FormattingEngine.ToggleItalic(document, r));
return true;
}
return false;
@@ -194,7 +194,7 @@ public bool HandleKeyDown(EditorKeyEventArgs e, Document.Document document, Docu
case EditorKey.U when ctrl:
if (FormattingEngine != null)
{
- ApplyFormattingWithSelectionRestore(document, r => FormattingEngine.ToggleUnderline(document, r));
+ ApplyFormattingWithSelectionRestore(document, "Underline", r => FormattingEngine.ToggleUnderline(document, r));
return true;
}
return false;
@@ -1076,11 +1076,20 @@ public void MovePageDown(Document.Document document, DocumentLayout layout, IDra
/// run splits and merges. Uses
/// so the selection is rebuilt from absolute character offsets after
/// is called on all affected paragraphs.
+ /// A non-empty selection is recorded as one undo step; an empty one only changes the
+ /// pending style and is not recorded.
///
- private void ApplyFormattingWithSelectionRestore(Document.Document document, Action applyOp)
+ private void ApplyFormattingWithSelectionRestore(Document.Document document, string description, Action applyOp)
{
var (newAnchor, newActive) = DocumentEditor.ApplyCharacterFormatting(
- document, _selection.Anchor, _selection.Active, applyOp);
+ document, _selection.Anchor, _selection.Active,
+ range =>
+ {
+ if (range.IsEmpty || UndoManager is null)
+ applyOp(range);
+ else
+ UndoManager.Push(new ApplyStyleAction(document, range, applyOp, description));
+ });
_selection.Set(newAnchor, newActive);
_caret.MoveTo(newActive);
}
diff --git a/src/MintedTextEditor.Maui/MintedEditorView.cs b/src/MintedTextEditor.Maui/MintedEditorView.cs
index 7f85e6b..3ca7f2b 100644
--- a/src/MintedTextEditor.Maui/MintedEditorView.cs
+++ b/src/MintedTextEditor.Maui/MintedEditorView.cs
@@ -643,7 +643,8 @@ private void WireToolbarCallbacks(ToolbarDefinition def)
if ((uint)idx < (uint)dd.Items.Count)
{
string family = idx == 0 ? "" : dd.Items[idx];
- ApplyFontFormatting(range => _fontFormattingEngine.ApplyFontFamily(_document, range, family));
+ ApplyToolbarCharacterFormatting("Font",
+ r => _fontFormattingEngine.ApplyFontFamily(_document, r, family));
}
else
{
@@ -659,7 +660,10 @@ private void WireToolbarCallbacks(ToolbarDefinition def)
dd.OnSelectionChanged = idx =>
{
if ((uint)idx < (uint)dd.Items.Count && float.TryParse(dd.Items[idx], out float sz))
- ApplyFontFormatting(range => _fontFormattingEngine.ApplyFontSize(_document, range, sz));
+ {
+ ApplyToolbarCharacterFormatting("Font Size",
+ r => _fontFormattingEngine.ApplyFontSize(_document, r, sz));
+ }
else
InvalidateCanvas();
};
@@ -772,14 +776,16 @@ private void WireToolbarCallbacks(ToolbarDefinition def)
case "text-color":
cp.OnColorSelected = color =>
{
- ApplyFontFormatting(range => _fontFormattingEngine.ApplyTextColor(_document, range, color));
+ ApplyToolbarCharacterFormatting("Text Color",
+ r => _fontFormattingEngine.ApplyTextColor(_document, r, color));
};
break;
case "highlight-color":
cp.OnColorSelected = color =>
{
- ApplyFontFormatting(range => _fontFormattingEngine.ApplyHighlightColor(_document, range, color));
+ ApplyToolbarCharacterFormatting("Highlight Color",
+ r => _fontFormattingEngine.ApplyHighlightColor(_document, r, color));
};
break;
}
@@ -788,21 +794,15 @@ private void WireToolbarCallbacks(ToolbarDefinition def)
}
///
- /// Applies a font family, size or colour change to the selection. Like character commands in
- /// , it goes through
- /// so the selection is rebuilt after runs
- /// are split, and the formatting state is then read from the formatted text rather than from
- /// stale endpoints.
+ /// Applies a toolbar dropdown or colour-picker format to the selection as one undo step
+ /// (or to the pending style when the selection is empty), keeping the selection on the same text,
+ /// then reads the formatting state back from the formatted text.
///
- private void ApplyFontFormatting(Action apply)
- {
- var (newAnchor, newActive) = DocumentEditor.ApplyCharacterFormatting(
- _document,
- _inputController.Selection.Anchor,
- _inputController.Selection.Active,
- apply);
- _inputController.Selection.Set(newAnchor, newActive);
+ private void ApplyToolbarCharacterFormatting(string description, Action apply)
+ {
+ GetEditorContext().ApplyCharacterFormatting(description, apply);
_caret.MoveTo(_inputController.Selection.Active);
+ RaiseSelectionChanged();
NotifyImeSelectionChanged();
UpdateFormattingState();
InvalidateCanvas();
diff --git a/tests/MintedTextEditor.Core.Tests/CharacterFormatUndoTests.cs b/tests/MintedTextEditor.Core.Tests/CharacterFormatUndoTests.cs
new file mode 100644
index 0000000..f180dee
--- /dev/null
+++ b/tests/MintedTextEditor.Core.Tests/CharacterFormatUndoTests.cs
@@ -0,0 +1,509 @@
+using MintedTextEditor.Core.Commands;
+using MintedTextEditor.Core.Document;
+using MintedTextEditor.Core.Editing;
+using MintedTextEditor.Core.Formatting;
+using MintedTextEditor.Core.Input;
+using MintedTextEditor.Core.Layout;
+using MintedTextEditor.Core.Rendering;
+
+namespace MintedTextEditor.Core.Tests;
+
+///
+/// Character-formatting commands, keyboard shortcuts and hyperlink removal must each record
+/// exactly one undo step that restores the previous runs with their exact styles.
+///
+public class CharacterFormatUndoTests
+{
+ private static readonly TextStyle Bold = TextStyle.Default.WithBold(true);
+ private static readonly TextStyle Italic = TextStyle.Default.WithItalic(true).WithFontSize(18);
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static Paragraph Runs(params (string Text, TextStyle Style)[] runs)
+ {
+ var p = new Paragraph();
+ foreach (var (text, style) in runs)
+ p.AppendRun(text, style);
+ return p;
+ }
+
+ /// "Hello " (bold), "big " (plain), "world" (italic, 18pt).
+ private static EditorDocument MixedDoc() =>
+ new([Runs(("Hello ", Bold), ("big ", TextStyle.Default), ("world", Italic))]);
+
+ /// Flattens the document into comparable (text, style) entries per block.
+ private static List> Dump(EditorDocument doc) =>
+ doc.Blocks.Select(b => b switch
+ {
+ Paragraph p => p.Inlines.Select(i => i switch
+ {
+ TextRun r => (object)(r.Text, r.Style),
+ _ => Describe(i),
+ }).ToList(),
+ _ => new List