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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **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.
- **Pending style follows the caret**: a format picked with nothing selected (e.g. Bold at a bare caret) now applies only at the caret where it was picked. Moving the caret (arrow keys, Home/End, a click, Backspace, or setting the selection from code) discards it, and the toolbar stops showing it; Enter still carries it into the new paragraph. New: `FormattingEngine`/`FontFormattingEngine.PendingStylePosition` and `DiscardPendingStyleIfMoved`. `TextRange.Empty` now holds positions outside any table cell (it used to be `default`, whose positions pointed at cell 0/0).
- **Plain-text paste formatting**: pasted text gets the same style as typed text at the caret (the pending style, else the caret's formatting) instead of `TextStyle.Default`, both in `EditorInputController.HandlePasteAsync` and `ClipboardOperations.PasteAsync` (caret formatting only).
- **IME after undo/redo**: `MintedEditorView.Undo()` and `Redo()` (also the toolbar buttons) now tell the platform keyboard that the text and selection changed, so Android and iOS keyboards don't keep editing stale text.
- **`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
4 changes: 2 additions & 2 deletions docs/formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ fmt.ToggleSuperscript(doc, range);
fmt.ClearFormatting(doc, range);
```

With an empty range (a collapsed caret), toggles set a pending style that is applied to the next text the user types. The pending style starts from the formatting at the caret, so toggling Bold inside bold text turns bold off for the next text and keeps the other formatting.
With an empty range (a collapsed caret), toggles set a pending style that is applied to the next text the user types. The pending style starts from the formatting at the caret, so toggling Bold inside bold text turns bold off for the next text and keeps the other formatting. The pending style applies only at the caret where it was picked (`PendingStylePosition`): moving the caret or selecting text discards it, while Enter carries it into the new paragraph. Hosts that move the selection themselves can call `DiscardPendingStyleIfMoved(doc, selection)` on `FormattingEngine` and `FontFormattingEngine`; the input controller, toolbar and `MintedEditorView` already do.

Without a pending style, typed text inherits the formatting of the character before the caret (or the character after it at the start of a paragraph; an empty paragraph uses `TextStyle.Default`). Pressing Enter carries that formatting into the new paragraph, so typing after Enter at the end of bold text stays bold. Typing or pasting at the start or end of a hyperlink with nothing selected adds plain text next to it rather than extending the link; text that replaces a selection inside a link (typing over it, or find and replace) stays in the link. `DocumentEditor.GetInsertionStyle(doc, position)` returns that inherited style, and `FormattingEngine.GetEffectiveCaretStyle(doc, position, editor.Formatting.PendingStyle, editor.FontFormatting.PendingStyle)` returns the style the next typed text will get. The toolbar's Bold, Italic, Underline, Strikethrough, Subscript and Superscript buttons show that style when nothing is selected.
Without a pending style, typed text inherits the formatting of the character before the caret (or the character after it at the start of a paragraph; an empty paragraph uses `TextStyle.Default`). Pressing Enter carries that formatting into the new paragraph, so typing after Enter at the end of bold text stays bold. Pasted plain text gets the same style as typed text. Typing or pasting at the start or end of a hyperlink with nothing selected adds plain text next to it rather than extending the link; text that replaces a selection inside a link (typing over it, or find and replace) stays in the link. `DocumentEditor.GetInsertionStyle(doc, position)` returns that inherited style, and `FormattingEngine.GetEffectiveCaretStyle(doc, position, editor.Formatting.PendingStyle, editor.FontFormatting.PendingStyle)` returns the style the next typed text will get. The toolbar's Bold, Italic, Underline, Strikethrough, Subscript and Superscript buttons show that style when nothing is selected.

### Setting Font Properties

Expand Down
4 changes: 2 additions & 2 deletions src/MintedTextEditor.Core/Document/TextRange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ namespace MintedTextEditor.Core.Document;
/// <summary>Whether the range is empty (start equals end).</summary>
public bool IsEmpty => Start == End;

/// <summary>An empty range at the document origin.</summary>
public static TextRange Empty => default;
/// <summary>An empty range at the document origin (outside any table cell).</summary>
public static TextRange Empty { get; } = new(new DocumentPosition(0, 0, 0), new DocumentPosition(0, 0, 0));

/// <summary>
/// Creates a range between two positions, swapping them if <paramref name="start"/>
Expand Down
5 changes: 3 additions & 2 deletions src/MintedTextEditor.Core/Editing/ClipboardOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public static async Task<DocumentPosition> CutAsync(Document.Document document,

/// <summary>
/// Pastes plain text from the clipboard at <paramref name="position"/>.
/// Replaces <paramref name="selectionRange"/> if it is not empty.
/// Replaces <paramref name="selectionRange"/> if it is not empty. The text gets the style typing
/// would inherit there (see <see cref="DocumentEditor.GetInsertionStyle"/>).
/// Returns the caret position after the paste, or null if the clipboard was empty.
/// </summary>
public static async Task<DocumentPosition?> PasteAsync(
Expand All @@ -45,6 +46,6 @@ public static async Task<DocumentPosition> CutAsync(Document.Document document,
if (!selectionRange.IsEmpty)
insertPos = DocumentEditor.DeleteRange(document, selectionRange);

return DocumentEditor.InsertText(document, insertPos, text);
return DocumentEditor.InsertText(document, insertPos, text, DocumentEditor.GetInsertionStyle(document, insertPos));
}
}
39 changes: 37 additions & 2 deletions src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,42 @@ public class FontFormattingEngine
{
/// <summary>
/// Style to apply on the next text insert when the caret is collapsed and font settings
/// are changed with no active selection.
/// are changed with no active selection. Discarded when the caret moves away
/// (see <see cref="DiscardPendingStyleIfMoved"/>).
/// </summary>
public TextStyle? PendingStyle { get; private set; }

/// <summary>The caret position where <see cref="PendingStyle"/> was chosen, or null when there is none.</summary>
public DocumentPosition? PendingStylePosition { get; private set; }

// Where the pending style was chosen, as a paragraph and character offset taken when it was chosen.
private PendingStyleAnchor? _pendingAnchor;

/// <summary>
/// Discards <see cref="PendingStyle"/> unless <paramref name="selection"/> is still the collapsed
/// caret where it was chosen, so a pending style applies only at that caret.
/// </summary>
/// <param name="doc">The document.</param>
/// <param name="selection">The current selection.</param>
public void DiscardPendingStyleIfMoved(Document.Document doc, TextRange selection)
{
if (PendingStyle is not null && !PendingStyleAnchor.IsCaretAt(doc, selection, _pendingAnchor))
SetPendingStyle(doc, null, null);
}

/// <summary>Re-anchors the pending style at <paramref name="position"/> (an edit such as Enter carried it there).</summary>
internal void MovePendingStyleTo(Document.Document doc, DocumentPosition position)
{
if (PendingStyle is not null) SetPendingStyle(doc, PendingStyle, position);
}

private void SetPendingStyle(Document.Document doc, TextStyle? style, DocumentPosition? position)
{
PendingStyle = style;
PendingStylePosition = style is null ? null : position;
_pendingAnchor = style is not null && position is { } p ? PendingStyleAnchor.At(doc, p) : null;
}

// ── Apply API ─────────────────────────────────────────────────────

/// <summary>Sets the font family of the text in <paramref name="range"/>. When <paramref name="range"/> is empty, updates <see cref="PendingStyle"/> instead.</summary>
Expand Down Expand Up @@ -44,6 +76,8 @@ public void RemoveHighlightColor(Document.Document doc, TextRange range)
{
var style = PendingStyle;
PendingStyle = null;
PendingStylePosition = null;
_pendingAnchor = null;
return style;
}

Expand Down Expand Up @@ -111,8 +145,9 @@ private void Apply(Document.Document doc, TextRange range, Func<TextStyle, TextS
{
if (range.IsEmpty)
{
DiscardPendingStyleIfMoved(doc, range);
var baseStyle = PendingStyle ?? DocumentEditor.GetInsertionStyle(doc, range.Start);
PendingStyle = transform(baseStyle);
SetPendingStyle(doc, transform(baseStyle), range.Start);
return;
}

Expand Down
69 changes: 66 additions & 3 deletions src/MintedTextEditor.Core/Formatting/FormattingEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,42 @@ public class FormattingEngine
{
/// <summary>
/// Style to apply on the next text insert when the caret is collapsed and the
/// user has toggled a format with no selection. Consumed after one use.
/// user has toggled a format with no selection. Consumed after one use, and discarded
/// when the caret moves away (see <see cref="DiscardPendingStyleIfMoved"/>).
/// </summary>
public TextStyle? PendingStyle { get; private set; }

/// <summary>The caret position where <see cref="PendingStyle"/> was chosen, or null when there is none.</summary>
public DocumentPosition? PendingStylePosition { get; private set; }

// Where the pending style was chosen, as a paragraph and character offset taken when it was chosen.
private PendingStyleAnchor? _pendingAnchor;

/// <summary>
/// Discards <see cref="PendingStyle"/> unless <paramref name="selection"/> is still the collapsed
/// caret where it was chosen, so a pending style applies only at that caret.
/// </summary>
/// <param name="doc">The document.</param>
/// <param name="selection">The current selection.</param>
public void DiscardPendingStyleIfMoved(Document.Document doc, TextRange selection)
{
if (PendingStyle is not null && !PendingStyleAnchor.IsCaretAt(doc, selection, _pendingAnchor))
SetPendingStyle(doc, null, null);
}

/// <summary>Re-anchors the pending style at <paramref name="position"/> (an edit such as Enter carried it there).</summary>
internal void MovePendingStyleTo(Document.Document doc, DocumentPosition position)
{
if (PendingStyle is not null) SetPendingStyle(doc, PendingStyle, position);
}

private void SetPendingStyle(Document.Document doc, TextStyle? style, DocumentPosition? position)
{
PendingStyle = style;
PendingStylePosition = style is null ? null : position;
_pendingAnchor = style is not null && position is { } p ? PendingStyleAnchor.At(doc, p) : null;
}

// ── Public toggle API ─────────────────────────────────────────────

/// <summary>
Expand Down Expand Up @@ -66,7 +98,7 @@ public void ClearFormatting(Document.Document doc, TextRange range)
{
if (range.IsEmpty)
{
PendingStyle = TextStyle.Default;
SetPendingStyle(doc, TextStyle.Default, range.Start);
return;
}
DocumentEditor.ApplyTextStyle(doc, range, _ => TextStyle.Default);
Expand All @@ -80,6 +112,8 @@ public void ClearFormatting(Document.Document doc, TextRange range)
{
var style = PendingStyle;
PendingStyle = null;
PendingStylePosition = null;
_pendingAnchor = null;
return style;
}

Expand Down Expand Up @@ -122,8 +156,9 @@ private void Toggle(
// No selection: update pending style so the next typed character picks it up.
// Start from the style the caret would otherwise type with, so toggling bold off
// inside bold text really turns it off and other formatting is kept.
DiscardPendingStyleIfMoved(doc, range);
var baseStyle = PendingStyle ?? DocumentEditor.GetInsertionStyle(doc, range.Start);
PendingStyle = setter(baseStyle, !getter(baseStyle));
SetPendingStyle(doc, setter(baseStyle, !getter(baseStyle)), range.Start);
return;
}

Expand Down Expand Up @@ -184,3 +219,31 @@ private static bool IsRunInRange(
return true;
}
}

/// <summary>
/// Where a pending style was chosen: the paragraph (block, and cell inside a table) and the character
/// offset in it, taken when the style was chosen so later edits to the runs don't shift it.
/// </summary>
internal readonly record struct PendingStyleAnchor(int BlockIndex, int CellRow, int CellCol, int Offset)
{
/// <summary>The anchor for <paramref name="position"/> in <paramref name="doc"/> as it is now.</summary>
public static PendingStyleAnchor At(Document.Document doc, DocumentPosition position)
{
var p = Normalize(doc, position);
return new PendingStyleAnchor(p.BlockIndex, p.CellRow, p.CellCol, DocumentEditor.GetAbsoluteCharOffset(doc, p));
}

/// <summary>
/// Whether <paramref name="selection"/> is a collapsed caret at <paramref name="anchor"/>: the same
/// paragraph and character offset, however the caret position is split across inlines.
/// </summary>
public static bool IsCaretAt(Document.Document doc, TextRange selection, PendingStyleAnchor? anchor)
=> anchor is { } a && selection.IsEmpty && At(doc, selection.Start) == a;

// Cell coordinates only mean something in a table block; elsewhere (e.g. default(DocumentPosition),
// whose cell is 0/0) they are dropped so the position matches the same spot outside a table.
private static DocumentPosition Normalize(Document.Document doc, DocumentPosition p)
=> p.IsInTableCell && !(p.BlockIndex >= 0 && p.BlockIndex < doc.Blocks.Count && doc.Blocks[p.BlockIndex] is TableBlock)
? new DocumentPosition(p.BlockIndex, p.InlineIndex, p.Offset)
: p;
}
Loading
Loading