Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
10 changes: 8 additions & 2 deletions docs/formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion docs/images-hyperlinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions src/MintedTextEditor.Core/Commands/EditorContext.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using MintedTextEditor.Core.Document;
using MintedTextEditor.Core.Editing;
using MintedTextEditor.Core.Formatting;

Expand Down Expand Up @@ -43,4 +44,31 @@ public EditorContext(
FontFormattingEngine = fontFormattingEngine;
Clipboard = clipboard;
}

/// <summary>
/// Applies a character-formatting operation to the current selection as one undo step,
/// keeping the selection on the same text afterwards.
/// </summary>
/// <remarks>
/// When the selection is empty, <paramref name="apply"/> runs directly and nothing is
/// recorded: the formatting engines only update their pending style in that case.
/// Otherwise an <see cref="ApplyStyleAction"/> is pushed to <see cref="UndoManager"/>.
/// </remarks>
/// <param name="description">Description of the undo step, e.g. "Bold".</param>
/// <param name="apply">
/// Formats the range it is given, e.g. <c>r =&gt; ctx.FormattingEngine.ToggleBold(ctx.Document, r)</c>.
/// </param>
public void ApplyCharacterFormatting(string description, Action<TextRange> 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);
}
}
28 changes: 18 additions & 10 deletions src/MintedTextEditor.Core/Commands/FontCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace MintedTextEditor.Core.Commands;
// ── Apply Font Family ─────────────────────────────────────────────────────────

/// <summary>Applies <see cref="FontFamily"/> to the selected text.</summary>
public sealed class ApplyFontFamilyCommand : IEditorCommand
public sealed class ApplyFontFamilyCommand : ICharacterFormattingCommand
{
/// <inheritdoc/>
public string Name => "ApplyFontFamily";
Expand All @@ -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));
}

/// <inheritdoc/>
Expand All @@ -31,7 +32,7 @@ public void Execute(EditorContext ctx)
// ── Apply Font Size ───────────────────────────────────────────────────────────

/// <summary>Applies <see cref="FontSize"/> to the selected text.</summary>
public sealed class ApplyFontSizeCommand : IEditorCommand
public sealed class ApplyFontSizeCommand : ICharacterFormattingCommand
{
/// <inheritdoc/>
public string Name => "ApplyFontSize";
Expand All @@ -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));
}

/// <inheritdoc/>
Expand All @@ -55,7 +57,7 @@ public void Execute(EditorContext ctx)
// ── Apply Text Color ──────────────────────────────────────────────────────────

/// <summary>Applies <see cref="Color"/> as the text colour of the selected text.</summary>
public sealed class ApplyTextColorCommand : IEditorCommand
public sealed class ApplyTextColorCommand : ICharacterFormattingCommand
{
/// <inheritdoc/>
public string Name => "ApplyTextColor";
Expand All @@ -66,8 +68,11 @@ public sealed class ApplyTextColorCommand : IEditorCommand
public EditorColor Color { get; set; } = EditorColor.Black;

/// <inheritdoc/>
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));
}

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -76,7 +81,7 @@ public void Execute(EditorContext ctx) =>
// ── Apply Highlight Color ─────────────────────────────────────────────────────

/// <summary>Applies <see cref="Color"/> as the highlight colour of the selected text.</summary>
public sealed class ApplyHighlightColorCommand : IEditorCommand
public sealed class ApplyHighlightColorCommand : ICharacterFormattingCommand
{
/// <inheritdoc/>
public string Name => "ApplyHighlightColor";
Expand All @@ -87,8 +92,11 @@ public sealed class ApplyHighlightColorCommand : IEditorCommand
public EditorColor Color { get; set; } = EditorColor.Yellow;

/// <inheritdoc/>
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));
}

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand Down
14 changes: 7 additions & 7 deletions src/MintedTextEditor.Core/Commands/FormattingCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class ToggleBoldCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ToggleBold(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Bold", r => ctx.FormattingEngine.ToggleBold(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -33,7 +33,7 @@ public sealed class ToggleItalicCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ToggleItalic(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Italic", r => ctx.FormattingEngine.ToggleItalic(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -51,7 +51,7 @@ public sealed class ToggleUnderlineCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ToggleUnderline(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Underline", r => ctx.FormattingEngine.ToggleUnderline(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -69,7 +69,7 @@ public sealed class ToggleStrikethroughCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ToggleStrikethrough(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Strikethrough", r => ctx.FormattingEngine.ToggleStrikethrough(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -87,7 +87,7 @@ public sealed class ToggleSubscriptCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ToggleSubscript(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Subscript", r => ctx.FormattingEngine.ToggleSubscript(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -105,7 +105,7 @@ public sealed class ToggleSuperscriptCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ToggleSuperscript(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Superscript", r => ctx.FormattingEngine.ToggleSuperscript(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand All @@ -123,7 +123,7 @@ public sealed class ClearFormattingCommand : ICharacterFormattingCommand

/// <inheritdoc/>
public void Execute(EditorContext ctx) =>
ctx.FormattingEngine.ClearFormatting(ctx.Document, ctx.Selection.Range);
ctx.ApplyCharacterFormatting("Clear Formatting", r => ctx.FormattingEngine.ClearFormatting(ctx.Document, r));

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) => true;
Expand Down
13 changes: 10 additions & 3 deletions src/MintedTextEditor.Core/Commands/HyperlinkCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ public void Execute(EditorContext ctx)

// ── Remove Hyperlink ──────────────────────────────────────────────────────────

/// <summary>Removes the hyperlink at the caret position.</summary>
/// <summary>
/// Removes the hyperlink at the caret position, keeping its text and formatting.
/// Recorded as one undo step.
/// </summary>
public sealed class RemoveHyperlinkCommand : IEditorCommand
{
/// <inheritdoc/>
Expand All @@ -52,8 +55,12 @@ public sealed class RemoveHyperlinkCommand : IEditorCommand
public string Description => "Removes the hyperlink at the current caret position.";

/// <inheritdoc/>
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);
}

/// <inheritdoc/>
public bool CanExecute(EditorContext ctx) =>
Expand Down
Loading
Loading