From 85bc0ab06d473a991ccbb759b3bf355a2984ab23 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:21:39 +0000 Subject: [PATCH 1/5] fix: formatting, hyperlinks and undo in and around tables; text before images - Deleting a selection from a paragraph before a table to one after it deletes the table with the selected text (unchanged), but undo lost the table and put the last paragraph back in the wrong place, because DeleteRangeAction only snapshotted paragraphs. Undo now records every block in the range (SnapshotHelper.CaptureBlocks/RestoreBlocks, keeping object identity) and reinserts the table at its index. The same snapshot covers table-cell paragraphs, so undoing a deletion inside a cell (e.g. Backspace) now restores the cell text instead of doing nothing. - Inserting a hyperlink with the caret or selection in a table cell threw (HyperlinkEngine looked up the paragraph by block index only). It now uses the cell's paragraph, undo restores it, and the caret lands after the link in the cell. A selection that runs out of the start cell links only its part in that cell. - Character formatting skipped table cells but still recorded an undo step. DocumentEditor.ApplyTextStyle, FormattingEngine.IsAppliedToEntireRange, FontFormattingEngine.GetTextStyleForRange, ApplyCharacterFormatting and ApplyStyleAction now walk the paragraphs of the cells in the range (reading order from the start cell to the end cell, as FormattingStateTracker already did), so bold etc. apply in cells and across several cells, toggles turn off again, and undo/redo restore the exact runs. GetSelectedText returns cell text too (it returned ""). - DocumentEditor.InsertText at offset 0 of an image (or other non-text inline) put the text after it; it now goes before it, appended to the preceding run when there is one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- CHANGELOG.md | 1 + docs/tables.md | 6 + .../Document/DocumentEditor.cs | 213 ++++++++---- .../Editing/UndoableActions.cs | 119 ++++--- .../Formatting/FontFormattingEngine.cs | 22 +- .../Formatting/FormattingEngine.cs | 41 +-- .../Formatting/HyperlinkEngine.cs | 27 +- .../TableEditingFixesTests.cs | 321 ++++++++++++++++++ 8 files changed, 595 insertions(+), 155 deletions(-) create mode 100644 tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 009ba26..c00bcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`SelectionChanged` after undo, redo and loading**: `MintedEditorView.Undo()` and `Redo()` raise `SelectionChanged` when they move the caret, and `LoadHtml`/`LoadMarkdown` raise it after resetting the caret. - **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`. +- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells, and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it. See [Tables](docs/tables.md#editing-cell-content). - **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. diff --git a/docs/tables.md b/docs/tables.md index 7028f23..67158d6 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -136,6 +136,12 @@ When the caret is inside a table cell: `Enter` does not split a paragraph inside a cell. +## Editing Cell Content + +- **Character formatting** (bold, italic, font, colours, clear formatting, …) applies to the selected text in a cell. A selection that spans several cells formats every cell between its start and end cell in reading order (row by row), and a selection that covers a whole table formats all of its cells. Each change is one undo step. +- **Hyperlinks** can be inserted at the caret or over the selected text in a cell. A link stays within one cell: when the selection runs into another cell, only its part in the start cell is linked. +- **Deleting a selection** that starts in a paragraph before a table and ends in a paragraph after it deletes the table along with the selected text and joins the two paragraphs. Undo puts the table and the text back. + ## HTML Export Tables export as standard HTML; each cell's content is written as its paragraphs (line breaks added here for readability): diff --git a/src/MintedTextEditor.Core/Document/DocumentEditor.cs b/src/MintedTextEditor.Core/Document/DocumentEditor.cs index e5f2e97..ad7cce7 100644 --- a/src/MintedTextEditor.Core/Document/DocumentEditor.cs +++ b/src/MintedTextEditor.Core/Document/DocumentEditor.cs @@ -149,6 +149,18 @@ public static DocumentPosition InsertText(Document document, DocumentPosition po NotifyTextInserted(document, position, endPos); return endPos; } + else if (position.Offset <= 0 && position.InlineIndex == inlineIndex) + { + // At the start of a non-text inline (e.g. an ImageInline) the text goes before it: + // appended to the run before it when there is one, otherwise as a new run. + if (inlineIndex > 0 && paragraph.Inlines[inlineIndex - 1] is TextRun previous) + return InsertText(document, position.With(inlineIndex - 1, previous.Text.Length), text, style); + + paragraph.InsertInline(inlineIndex, new TextRun(text, style)); + var newPos = position.With(inlineIndex, text.Length); + NotifyTextInserted(document, position, newPos); + return newPos; + } else { // For non-text, non-hyperlink inlines (e.g. ImageInline), insert a new run after. @@ -394,60 +406,118 @@ public static DocumentPosition MergeBlocks(Document document, int blockIndex) } /// - /// Applies a style transformation to all text runs within the given range. + /// Applies a style transformation to all text runs within the given range, including the + /// text of the table cells the range covers (see ). /// public static void ApplyTextStyle(Document document, TextRange range, Func transform) { if (range.IsEmpty) return; + foreach (var (paragraph, from, to) in GetParagraphsInRange(document, range)) + ApplyTextStyleToParagraph(paragraph, from, to, transform); + + document.NotifyChanged(new DocumentChangedEventArgs(DocumentChangeType.StyleChanged, range)); + } + + /// + /// Applies to the text runs of between + /// and (null: the paragraph's start or end). + /// + private static void ApplyTextStyleToParagraph(Paragraph paragraph, DocumentPosition? from, DocumentPosition? to, Func transform) + { + // Traverse backwards: splitting a run inserts new runs after it, which would + // shift the indices of not-yet-processed runs (including the end run) in a + // forward loop and leave part of the selection unformatted. + for (int ii = paragraph.Inlines.Count - 1; ii >= 0; ii--) + { + if (paragraph.Inlines[ii] is not TextRun run) continue; + + bool isFirst = from is { } f && ii == f.InlineIndex; + bool isLast = to is { } t && ii == t.InlineIndex; + + if (isFirst && isLast) + { + // Range within a single run: split if needed. Nothing before it is in range. + ApplyStyleToRunSegment(paragraph, ii, from!.Value.Offset, to!.Value.Offset, transform); + break; + } + else if (isFirst) + { + ApplyStyleToRunSegment(paragraph, ii, from!.Value.Offset, run.Text.Length, transform); + } + else if (isLast) + { + ApplyStyleToRunSegment(paragraph, ii, 0, to!.Value.Offset, transform); + } + else + { + // A run that is neither the first nor the last in the range receives the + // transform only when it lies STRICTLY INSIDE the range: after the start + // boundary AND before the end boundary. Requiring both halves prevents the + // transform from leaking to runs past the end inline when the selection + // spans inlines in a single paragraph. + bool afterStart = from is not { } fs || ii > fs.InlineIndex; + bool beforeEnd = to is not { } te || ii < te.InlineIndex; + if (afterStart && beforeEnd) + run.Style = transform(run.Style); + } + } + } + + /// + /// Returns the paragraphs that covers, in document order: top-level + /// paragraphs, and the paragraphs of the table cells in the range. Within a table the cells are + /// taken in reading order (row by row), from the range's start cell (or the first cell when the + /// range starts before the table) to its end cell (or the last cell when it ends after it). + /// From and To are the range's endpoints when they lie in that paragraph, and null + /// when the paragraph is covered from its start or to its end. A position in a cell refers to the + /// cell's first paragraph, so later paragraphs of the end cell are not included. + /// + internal static List<(Paragraph Paragraph, DocumentPosition? From, DocumentPosition? To)> GetParagraphsInRange( + Document document, TextRange range) + { + var result = new List<(Paragraph, DocumentPosition?, DocumentPosition?)>(); var start = range.Start; var end = range.End; - for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < document.Blocks.Count; bi++) + for (int bi = Math.Max(0, start.BlockIndex); bi <= end.BlockIndex && bi < document.Blocks.Count; bi++) { - var paragraph = document.Blocks[bi] as Paragraph; - if (paragraph == null) continue; - - // Traverse backwards: splitting a run inserts new runs after it, which would - // shift the indices of not-yet-processed runs (including the end run) in a - // forward loop and leave part of the selection unformatted. - for (int ii = paragraph.Inlines.Count - 1; ii >= 0; ii--) + switch (document.Blocks[bi]) { - if (paragraph.Inlines[ii] is not TextRun run) continue; + case Paragraph paragraph: + result.Add((paragraph, + bi == start.BlockIndex && !start.IsInTableCell ? start : null, + bi == end.BlockIndex && !end.IsInTableCell ? end : null)); + break; - bool isFirst = bi == start.BlockIndex && ii == start.InlineIndex; - bool isLast = bi == end.BlockIndex && ii == end.InlineIndex; + case TableBlock table: + bool startsInTable = bi == start.BlockIndex && start.IsInTableCell; + bool endsInTable = bi == end.BlockIndex && end.IsInTableCell; + for (int r = 0; r < table.Rows.Count; r++) + { + for (int c = 0; c < table.Rows[r].Cells.Count; c++) + { + if (startsInTable && (r, c).CompareTo((start.CellRow, start.CellCol)) < 0) continue; + if (endsInTable && (r, c).CompareTo((end.CellRow, end.CellCol)) > 0) continue; + bool isStartCell = startsInTable && r == start.CellRow && c == start.CellCol; + bool isEndCell = endsInTable && r == end.CellRow && c == end.CellCol; - if (isFirst && isLast) - { - // Range within a single run: split if needed. Nothing before it is in range. - ApplyStyleToRunSegment(paragraph, ii, start.Offset, end.Offset, transform); + var blocks = table.Rows[r].Cells[c].Blocks; + for (int k = 0; k < blocks.Count; k++) + { + if (isEndCell && k > 0) break; + if (blocks[k] is not Paragraph cellParagraph) continue; + result.Add((cellParagraph, + isStartCell && k == 0 ? start : null, + isEndCell ? end : null)); + } + } + } break; - } - else if (isFirst) - { - ApplyStyleToRunSegment(paragraph, ii, start.Offset, run.Text.Length, transform); - } - else if (isLast) - { - ApplyStyleToRunSegment(paragraph, ii, 0, end.Offset, transform); - } - else - { - // A run that is neither the first nor the last in the range receives the - // transform only when it lies STRICTLY INSIDE the range: after the start - // boundary AND before the end boundary. (Whole intermediate blocks are a - // subset of this.) Requiring both halves prevents the transform from leaking - // to runs past end.InlineIndex when the selection spans inlines in a single block. - bool afterStart = bi > start.BlockIndex || (bi == start.BlockIndex && ii > start.InlineIndex); - bool beforeEnd = bi < end.BlockIndex || (bi == end.BlockIndex && ii < end.InlineIndex); - if (afterStart && beforeEnd) - run.Style = transform(run.Style); - } } } - document.NotifyChanged(new DocumentChangedEventArgs(DocumentChangeType.StyleChanged, range)); + return result; } /// @@ -478,6 +548,21 @@ public static string GetSelectedText(Document document, TextRange range) var end = range.End; var sb = new System.Text.StringBuilder(); + if (start.IsInTableCell || end.IsInTableCell) + { + // Text in table cells: one line per covered paragraph (see GetParagraphsInRange). + var paragraphs = GetParagraphsInRange(document, range); + for (int i = 0; i < paragraphs.Count; i++) + { + if (i > 0) sb.Append('\n'); + var (para, from, to) = paragraphs[i]; + AppendRunText(sb, para, + from ?? new DocumentPosition(0, 0, 0), + to ?? new DocumentPosition(0, para.Inlines.Count - 1, int.MaxValue)); + } + return sb.ToString(); + } + for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < document.Blocks.Count; bi++) { if (bi > start.BlockIndex) @@ -485,24 +570,32 @@ public static string GetSelectedText(Document document, TextRange range) if (document.Blocks[bi] is not Paragraph para) continue; - int startInline = bi == start.BlockIndex ? start.InlineIndex : 0; - int endInline = bi == end.BlockIndex ? end.InlineIndex : para.Inlines.Count - 1; + AppendRunText(sb, para, + bi == start.BlockIndex ? start : new DocumentPosition(bi, 0, 0), + bi == end.BlockIndex ? end : new DocumentPosition(bi, para.Inlines.Count - 1, int.MaxValue)); + } - for (int ii = startInline; ii <= endInline && ii < para.Inlines.Count; ii++) - { - if (para.Inlines[ii] is not TextRun run) continue; + return sb.ToString(); + } + + /// + /// Appends the text of the text runs of from + /// to (inline index and offset pairs within ). + /// + private static void AppendRunText(System.Text.StringBuilder sb, Paragraph para, DocumentPosition from, DocumentPosition to) + { + for (int ii = from.InlineIndex; ii <= to.InlineIndex && ii < para.Inlines.Count; ii++) + { + if (para.Inlines[ii] is not TextRun run) continue; - int startOffset = bi == start.BlockIndex && ii == startInline ? start.Offset : 0; - int endOffset = bi == end.BlockIndex && ii == endInline ? end.Offset : run.Text.Length; + int startOffset = ii == from.InlineIndex ? from.Offset : 0; + int endOffset = ii == to.InlineIndex ? to.Offset : run.Text.Length; - startOffset = Math.Clamp(startOffset, 0, run.Text.Length); - endOffset = Math.Clamp(endOffset, 0, run.Text.Length); - if (startOffset < endOffset) - sb.Append(run.Text, startOffset, endOffset - startOffset); - } + startOffset = Math.Clamp(startOffset, 0, run.Text.Length); + endOffset = Math.Clamp(endOffset, 0, run.Text.Length); + if (startOffset < endOffset) + sb.Append(run.Text, startOffset, endOffset - startOffset); } - - return sb.ToString(); } private static Paragraph GetParagraph(Document document, int blockIndex) @@ -516,7 +609,7 @@ private static Paragraph GetParagraph(Document document, int blockIndex) /// /// Returns the paragraph for the given position, routing through table-cell context when needed. /// - private static Paragraph GetParagraph(Document document, DocumentPosition position) + internal static Paragraph GetParagraph(Document document, DocumentPosition position) { if (position.IsInTableCell) { @@ -836,15 +929,11 @@ public static (DocumentPosition Anchor, DocumentPosition Active) ApplyCharacterF if (range.IsEmpty) return (anchor, active); - // 3. Normalize inlines in every paragraph touched by the range (re-merge same-style - // adjacent runs, drop empties) so the inline structure is clean before we remap. - var start = range.Start; - var end = range.End; - for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < document.Blocks.Count; bi++) - { - if (document.Blocks[bi] is Paragraph para) - NormalizeInlines(para); - } + // 3. Normalize inlines in every paragraph touched by the range, table cells included + // (re-merge same-style adjacent runs, drop empties) so the inline structure is clean + // before we remap. + foreach (var (para, _, _) in GetParagraphsInRange(document, range)) + NormalizeInlines(para); // 4. Rebuild Anchor and Active from the captured absolute offsets. var newAnchor = AbsoluteCharOffsetToPosition(document, anchor, anchorAbs); diff --git a/src/MintedTextEditor.Core/Editing/UndoableActions.cs b/src/MintedTextEditor.Core/Editing/UndoableActions.cs index 493e6d9..4c5c958 100644 --- a/src/MintedTextEditor.Core/Editing/UndoableActions.cs +++ b/src/MintedTextEditor.Core/Editing/UndoableActions.cs @@ -16,6 +16,9 @@ internal record HyperlinkInlineSnapshot(HyperlinkInline Link, string Url, string // Inlines without mutable state of their own (LineBreak, and any other inline type). internal record OpaqueInlineSnapshot(Inline Inline) : InlineSnapshot(Inline); internal record ParagraphSnapshot(Paragraph Paragraph, ParagraphStyle Style, IReadOnlyList Inlines); +// A top-level block together with snapshots of the paragraphs in it that an edit may change +// (the block itself for a paragraph; the cell paragraphs in the edited range for a table). +internal record BlockSnapshot(Block Block, IReadOnlyList Paragraphs); /// /// A position stored as a character offset within its paragraph (or table-cell paragraph) @@ -115,26 +118,57 @@ internal static Paragraph CreateParagraph(ParagraphSnapshot snapshot) } /// - /// Restores the paragraphs recorded from consecutive blocks starting at . - /// A recorded paragraph still in the document is restored in place; one that was removed (merged - /// away) is reinserted at its original index. Returns whether any paragraph was reinserted, in - /// which case later blocks have moved and the caller must report a structural change. + /// Records every top-level block from 's start block to its end block, + /// tables included, with snapshots of the paragraphs an edit of the range may change: each + /// top-level paragraph, and the cell paragraphs of a table that are in the range. /// - internal static bool RestoreBlocks(Document.Document document, int startBlock, IReadOnlyList snapshots) + internal static List CaptureBlocks(Document.Document document, TextRange range) + { + var cellParagraphs = DocumentEditor.GetParagraphsInRange(document, range) + .Select(x => x.Paragraph) + .ToHashSet(); + + var snapshots = new List(); + for (int i = Math.Max(0, range.Start.BlockIndex); i <= range.End.BlockIndex && i < document.Blocks.Count; i++) + { + var block = document.Blocks[i]; + IReadOnlyList paragraphs = block switch + { + Paragraph p => [Capture(p)], + TableBlock t => t.Rows + .SelectMany(r => r.Cells) + .SelectMany(c => c.Blocks) + .OfType() + .Where(cellParagraphs.Contains) + .Select(Capture) + .ToList(), + _ => [], + }; + snapshots.Add(new BlockSnapshot(block, paragraphs)); + } + return snapshots; + } + + /// + /// Restores the consecutive blocks recorded by starting at + /// . A recorded block that was removed (merged or deleted away, + /// a table included) is reinserted at its original index, and every recorded paragraph is + /// restored in place. Returns whether any block was reinserted, in which case later blocks + /// have moved and the caller must report a structural change. + /// + internal static bool RestoreBlocks(Document.Document document, int startBlock, IReadOnlyList snapshots) { bool reinserted = false; for (int i = 0; i < snapshots.Count; i++) { - var p = snapshots[i].Paragraph; - if (document.Blocks.Contains(p)) + var block = snapshots[i].Block; + if (!document.Blocks.Contains(block)) { - Restore(p, snapshots[i]); - } - else - { - document.InsertBlock(Math.Min(startBlock + i, document.Blocks.Count), CreateParagraph(snapshots[i])); + document.InsertBlock(Math.Min(startBlock + i, document.Blocks.Count), block); reinserted = true; } + foreach (var paragraph in snapshots[i].Paragraphs) + Restore(paragraph.Paragraph, paragraph); } return reinserted; } @@ -244,8 +278,9 @@ insert._startAnchor is { } nextStart && // ── DeleteRangeAction ─────────────────────────────────────────────────────── /// -/// Records a range deletion. Snapshots all affected paragraphs before the delete -/// so they can be fully restored on undo. +/// Records a range deletion. Snapshots all affected blocks before the delete (paragraphs, the +/// table-cell paragraphs in the range, and any table between the range's ends, which the delete +/// removes) so they can be fully restored on undo. /// public sealed class DeleteRangeAction : IUndoableAction { @@ -253,7 +288,7 @@ public sealed class DeleteRangeAction : IUndoableAction private TextRange _range; private readonly ParagraphAnchor _startAnchor; private readonly ParagraphAnchor _endAnchor; - private List _snapshots = []; + private List _snapshots = []; /// Creates an action that deletes from the document. public DeleteRangeAction(Document.Document document, TextRange range) @@ -271,12 +306,7 @@ public DeleteRangeAction(Document.Document document, TextRange range) public DocumentPosition Execute() { _range = new TextRange(_startAnchor.Resolve(_document), _endAnchor.Resolve(_document)); - _snapshots.Clear(); - 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 = SnapshotHelper.CaptureBlocks(_document, _range); return DocumentEditor.DeleteRange(_document, _range); } @@ -285,8 +315,9 @@ public DocumentPosition Undo() { if (_snapshots.Count == 0) return _range.Start; - // Restore the first paragraph in place and re-insert any merged away by a multi-block range. - // Reinserted blocks shift every later block, so report a structural change for them. + // Restore the first paragraph in place and re-insert the blocks (paragraphs and tables) + // removed by a multi-block range. Reinserted blocks shift every later block, so report a + // structural change for them. bool reinserted = SnapshotHelper.RestoreBlocks(_document, _range.Start.BlockIndex, _snapshots); _document.NotifyChanged(new DocumentChangedEventArgs( reinserted ? DocumentChangeType.BlockSplit : DocumentChangeType.TextInserted, _range)); @@ -429,7 +460,7 @@ public sealed class ApplyStyleAction : IUndoableAction private readonly ParagraphAnchor _startAnchor; private readonly ParagraphAnchor _endAnchor; private readonly Action _apply; - private readonly List<(int BlockIndex, ParagraphSnapshot Snapshot)> _snapshots = []; + private readonly List _snapshots = []; /// Creates an action that applies to the style of the text in . public ApplyStyleAction(Document.Document document, TextRange range, Func transform) @@ -468,30 +499,22 @@ public DocumentPosition Execute() { _range = new TextRange(_startAnchor.Resolve(_document), _endAnchor.Resolve(_document)); _snapshots.Clear(); - for (int i = _range.Start.BlockIndex; i <= _range.End.BlockIndex && i < _document.Blocks.Count; i++) - { - if (_document.Blocks[i] is Paragraph p) - _snapshots.Add((i, SnapshotHelper.Capture(p))); - } + // Top-level paragraphs and the table-cell paragraphs in the range. + foreach (var (p, _, _) in DocumentEditor.GetParagraphsInRange(_document, _range)) + _snapshots.Add(SnapshotHelper.Capture(p)); _apply(_range); - foreach (var (blockIndex, _) in _snapshots) - { - if (blockIndex < _document.Blocks.Count && _document.Blocks[blockIndex] is Paragraph p) - DocumentEditor.NormalizeInlines(p); - } + foreach (var snapshot in _snapshots) + DocumentEditor.NormalizeInlines(snapshot.Paragraph); return _endAnchor.Resolve(_document); } /// public DocumentPosition Undo() { - foreach (var (blockIndex, snapshot) in _snapshots) - { - if (blockIndex < _document.Blocks.Count && _document.Blocks[blockIndex] is Paragraph p) - RestoreInlines(p, snapshot); - } + foreach (var snapshot in _snapshots) + RestoreInlines(snapshot.Paragraph, snapshot); _document.NotifyChanged(new DocumentChangedEventArgs(DocumentChangeType.StyleChanged, _range)); return _endAnchor.Resolve(_document); } @@ -892,7 +915,7 @@ public sealed class InsertHyperlinkAction : IUndoableAction private readonly ParagraphAnchor _endAnchor; private readonly string _url; private readonly string? _displayText; - private readonly List _snapshots = []; + private List _snapshots = []; /// Creates an action that inserts a hyperlink to over . public InsertHyperlinkAction(Document.Document document, TextRange originalRange, string url) @@ -923,20 +946,12 @@ public InsertHyperlinkAction(Document.Document document, TextRange originalRange public DocumentPosition Execute() { _range = new TextRange(_startAnchor.Resolve(_document), _endAnchor.Resolve(_document)); - _snapshots.Clear(); - 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 = SnapshotHelper.CaptureBlocks(_document, _range); var inserted = HyperlinkEngine.InsertHyperlink(_document, _range, _url, title: null, _displayText); - if (inserted.Parent is Paragraph para) - { - int blockIdx = _document.Blocks.IndexOf(para); - int linkIdx = para.Inlines.IndexOf(inserted); - return new DocumentPosition(blockIdx, linkIdx, inserted.Length); - } + // The link is in the range's start paragraph (a top-level paragraph or a table cell's). + if (inserted.Parent is Paragraph para && para.Inlines.IndexOf(inserted) is var linkIdx and >= 0) + return _range.Start.With(linkIdx, inserted.Length); return _range.Start; } diff --git a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs index b3968e6..8b89a9b 100644 --- a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs +++ b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs @@ -83,17 +83,13 @@ public static TextStyle GetCurrentTextStyle(Document.Document doc, DocumentPosit TextStyle? common = null; - var start = range.Start; - var end = range.End; - - for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < doc.Blocks.Count; bi++) + // Top-level paragraphs and the paragraphs of the table cells in the range. + foreach (var (para, from, to) in DocumentEditor.GetParagraphsInRange(doc, range)) { - if (doc.Blocks[bi] is not Paragraph para) continue; - for (int ii = 0; ii < para.Inlines.Count; ii++) { if (para.Inlines[ii] is not TextRun run) continue; - if (!IsRunInRange(bi, ii, run, start, end)) continue; + if (!FormattingEngine.IsRunInRange(ii, run, from, to)) continue; if (common is null) common = run.Style; @@ -118,16 +114,4 @@ private void Apply(Document.Document doc, TextRange range, Func end.BlockIndex) return false; - if (bi == start.BlockIndex && ii < start.InlineIndex) return false; - if (bi == end.BlockIndex && ii > end.InlineIndex) return false; - if (bi == start.BlockIndex && ii == start.InlineIndex && start.Offset >= run.Text.Length) return false; - if (bi == end.BlockIndex && ii == end.InlineIndex && end.Offset <= 0) return false; - return true; - } } diff --git a/src/MintedTextEditor.Core/Formatting/FormattingEngine.cs b/src/MintedTextEditor.Core/Formatting/FormattingEngine.cs index 9784ba9..f5a97a7 100644 --- a/src/MintedTextEditor.Core/Formatting/FormattingEngine.cs +++ b/src/MintedTextEditor.Core/Formatting/FormattingEngine.cs @@ -142,18 +142,15 @@ public static bool IsAppliedToEntireRange( { if (range.IsEmpty) return false; - var start = range.Start; - var end = range.End; bool foundAnyRun = false; - for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < doc.Blocks.Count; bi++) + // Top-level paragraphs and the paragraphs of the table cells in the range. + foreach (var (para, from, to) in DocumentEditor.GetParagraphsInRange(doc, range)) { - if (doc.Blocks[bi] is not Paragraph para) continue; - for (int ii = 0; ii < para.Inlines.Count; ii++) { if (para.Inlines[ii] is not TextRun run) continue; - if (!IsRunInRange(bi, ii, run, start, end)) continue; + if (!IsRunInRange(ii, run, from, to)) continue; foundAnyRun = true; if (!getter(run.Style)) @@ -164,22 +161,26 @@ public static bool IsAppliedToEntireRange( return foundAnyRun; } - private static bool IsRunInRange( - int bi, int ii, TextRun run, - DocumentPosition start, DocumentPosition end) + /// + /// Whether the run at inline index of a paragraph shares text with the part + /// of the range in that paragraph, which runs from to + /// (null: the paragraph's start or end). + /// + internal static bool IsRunInRange(int ii, TextRun run, DocumentPosition? from, DocumentPosition? to) { - if (bi < start.BlockIndex || bi > end.BlockIndex) return false; - - if (bi == start.BlockIndex && ii < start.InlineIndex) return false; - if (bi == end.BlockIndex && ii > end.InlineIndex) return false; - - // At the exact first inline: make sure the range doesn't start after the run's text ends - if (bi == start.BlockIndex && ii == start.InlineIndex && start.Offset >= run.Text.Length) - return false; + if (from is { } start) + { + if (ii < start.InlineIndex) return false; + // At the exact first inline: make sure the range doesn't start after the run's text ends + if (ii == start.InlineIndex && start.Offset >= run.Text.Length) return false; + } - // At the exact last inline: make sure the range doesn't end before the run's text starts - if (bi == end.BlockIndex && ii == end.InlineIndex && end.Offset <= 0) - return false; + if (to is { } end) + { + if (ii > end.InlineIndex) return false; + // At the exact last inline: make sure the range doesn't end before the run's text starts + if (ii == end.InlineIndex && end.Offset <= 0) return false; + } return true; } diff --git a/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs b/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs index 4d7bb75..18ed043 100644 --- a/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs +++ b/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs @@ -34,10 +34,16 @@ public static HyperlinkInline InsertHyperlink( /// becomes the link text, or the URL is inserted as the text when the range is empty. /// Returns the inserted . /// + /// + /// The link can be inserted in a table cell. A link stays within one paragraph, so when + /// starts or ends in a table cell and does not lie within that one cell, + /// only its part from its start to the end of the start paragraph (or cell) is used. + /// public static HyperlinkInline InsertHyperlink( Document.Document doc, TextRange range, string url, string? title, string? displayText) { - var para = GetParagraph(doc, range.Start.BlockIndex); + range = ClampToStartParagraphInTable(doc, range); + var para = DocumentEditor.GetParagraph(doc, range.Start); bool hasDisplayText = !string.IsNullOrWhiteSpace(displayText); string linkText; @@ -58,7 +64,7 @@ public static HyperlinkInline InsertHyperlink( hyperlink.AddChild(new TextRun(linkText)); // Refresh paragraph reference after deletion - para = GetParagraph(doc, insertPos.BlockIndex); + para = DocumentEditor.GetParagraph(doc, insertPos); InsertHyperlinkInline(para, insertPos, hyperlink); doc.NotifyChanged(new DocumentChangedEventArgs( @@ -176,6 +182,23 @@ public static void RemoveHyperlink(Document.Document doc, DocumentPosition posit // ── Helpers ─────────────────────────────────────────────────────── + /// + /// When starts or ends in a table cell but does not lie within one + /// paragraph, returns the range from its start to the end of its start paragraph (or cell). + /// Deleting such a range does not join the paragraphs, so the link could not replace it. + /// Otherwise returns unchanged. + /// + internal static TextRange ClampToStartParagraphInTable(Document.Document doc, TextRange range) + { + var start = range.Start; + var end = range.End; + if (!start.IsInTableCell && !end.IsInTableCell) + return range; + if (start.BlockIndex == end.BlockIndex && start.CellRow == end.CellRow && start.CellCol == end.CellCol) + return range; + return new TextRange(start, DocumentEditor.AbsoluteCharOffsetToPosition(doc, start, int.MaxValue)); + } + private static Paragraph GetParagraph(Document.Document doc, int blockIndex) { if (blockIndex < 0 || blockIndex >= doc.Blocks.Count) diff --git a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs new file mode 100644 index 0000000..497fe3a --- /dev/null +++ b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs @@ -0,0 +1,321 @@ +using MintedTextEditor.Core.Commands; +using MintedTextEditor.Core.Document; +using MintedTextEditor.Core.Editing; +using MintedTextEditor.Core.Formatting; + +namespace MintedTextEditor.Core.Tests; + +/// +/// Editing around and inside tables: deleting a selection across a table, hyperlinks and +/// character formatting in table cells, and inserting text before an image. Each edit must be +/// undoable and redoable exactly. +/// +public class TableEditingFixesTests +{ + private static readonly TextStyle Bold = TextStyle.Default.WithBold(true); + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// A 2x2 table whose cells hold "A1", "B1", "A2", "B2". + private static TableBlock Table() + { + var table = new TableBlock(2, 2); + for (int r = 0; r < 2; r++) + for (int c = 0; c < 2; c++) + ((Paragraph)table.GetCell(r, c)!.Blocks[0]).AppendRun($"{(char)('A' + c)}{r + 1}"); + return table; + } + + private static Paragraph CellParagraph(TableBlock table, int row, int col) + => (Paragraph)table.GetCell(row, col)!.Blocks[0]; + + private static DocumentPosition Cell(int block, int row, int col, int inline, int offset) + => new(block, inline, offset, row, col); + + /// Lossless text description of the document, recursing into tables and hyperlinks. + private static string Dump(EditorDocument doc) => string.Join(" | ", doc.Blocks.Select(Describe)); + + private static string Describe(Block block) => block switch + { + Paragraph p => "P[" + string.Join(",", p.Inlines.Select(Describe)) + "]", + TableBlock t => "T[" + string.Join(";", t.Rows.Select(r => + string.Join("/", r.Cells.Select(c => string.Join("+", c.Blocks.Select(b => + b is Paragraph p ? string.Join(",", p.Inlines.Select(Describe)) : Describe(b))))))) + "]", + _ => block.GetType().Name, + }; + + private static string Describe(Inline inline) => inline switch + { + TextRun r => $"'{r.Text}'{(r.Style.IsBold ? "b" : "")}", + ImageInline img => $"I({img.Source})", + HyperlinkInline h => $"L({h.Url}:{string.Join(",", h.Children.Select(Describe))})", + _ => inline.GetType().Name, + }; + + private static EditorContext Ctx(EditorDocument doc, DocumentPosition anchor, DocumentPosition active, UndoManager undo) + { + var sel = new Selection(); + sel.Set(anchor, active); + return new EditorContext(doc, sel, undo, new FormattingEngine(), new FontFormattingEngine()); + } + + // ── 1. Deleting a selection across a table ──────────────────────────────── + + [Fact] + public void DeleteRange_AcrossTable_RemovesTableAndUndoRestoresItExactly() + { + var first = new Paragraph("Hello"); + var table = Table(); + var last = new Paragraph("World"); + var doc = new EditorDocument([first, table, last]); + string before = Dump(doc); + var undo = new UndoManager(); + + undo.Push(new DeleteRangeAction(doc, new TextRange(new(0, 0, 2), new(2, 0, 3)))); + + Assert.Equal("P['Held']", Dump(doc)); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + Assert.Same(first, doc.Blocks[0]); + Assert.Same(table, doc.Blocks[1]); + Assert.Same(last, doc.Blocks[2]); + Assert.All(doc.Blocks, b => Assert.Same(doc, b.Parent)); + + undo.Redo(); + Assert.Equal("P['Held']", Dump(doc)); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + Assert.Same(table, doc.Blocks[1]); + } + + [Fact] + public void DeleteRange_InsideTableCell_UndoRestoresCellText() + { + var table = Table(); + var doc = new EditorDocument([new Paragraph("x"), table]); + string before = Dump(doc); + var undo = new UndoManager(); + + undo.Push(new DeleteRangeAction(doc, new TextRange(Cell(1, 0, 1, 0, 0), Cell(1, 0, 1, 0, 1)))); + Assert.Equal("1", CellParagraph(table, 0, 1).GetText()); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + + undo.Redo(); + Assert.Equal("1", CellParagraph(table, 0, 1).GetText()); + } + + // ── 2. Hyperlinks in table cells ────────────────────────────────────────── + + [Fact] + public void InsertHyperlink_AtCaretInTableCell_InsertsLinkAndUndoes() + { + var table = Table(); + var doc = new EditorDocument([new Paragraph("x"), table]); + string before = Dump(doc); + var undo = new UndoManager(); + var caret = Cell(1, 1, 0, 0, 2); + + var pos = undo.Push(new InsertHyperlinkAction(doc, new TextRange(caret, caret), "https://a.b", "link")); + + Assert.Equal("P['x'] | T['A1'/'B1';'A2',L(https://a.b:'link')/'B2']", Dump(doc)); + Assert.Equal(Cell(1, 1, 0, 1, 4), pos); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + + undo.Redo(); + Assert.Equal("P['x'] | T['A1'/'B1';'A2',L(https://a.b:'link')/'B2']", Dump(doc)); + } + + [Fact] + public void InsertHyperlinkCommand_OverSelectionInTableCell_LinksSelectedText() + { + var table = Table(); + var doc = new EditorDocument([table]); + string before = Dump(doc); + var undo = new UndoManager(); + var ctx = Ctx(doc, Cell(0, 0, 1, 0, 0), Cell(0, 0, 1, 0, 1), undo); + + new InsertHyperlinkCommand { Url = "https://a.b" }.Execute(ctx); + + Assert.Equal("T['A1'/L(https://a.b:'B'),'1';'A2'/'B2']", Dump(doc)); + Assert.Equal(Cell(0, 0, 1, 0, 1), ctx.Selection.Active); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + } + + [Fact] + public void InsertHyperlink_SelectionAcrossCells_LinksOnlyTheStartCell() + { + var table = Table(); + var doc = new EditorDocument([table]); + string before = Dump(doc); + var undo = new UndoManager(); + + undo.Push(new InsertHyperlinkAction(doc, new TextRange(Cell(0, 0, 0, 0, 1), Cell(0, 1, 1, 0, 1)), "https://a.b")); + + Assert.Equal("T['A',L(https://a.b:'1')/'B1';'A2'/'B2']", Dump(doc)); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + } + + [Fact] + public void GetSelectedText_InTableCells_ReturnsCellText() + { + var doc = new EditorDocument([Table()]); + + Assert.Equal("1", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 1, 0, 1), Cell(0, 0, 1, 0, 2)))); + Assert.Equal("1\nA2\nB", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 1, 0, 1), Cell(0, 1, 1, 0, 1)))); + } + + // ── 3. Character formatting in table cells ──────────────────────────────── + + [Fact] + public void ToggleBold_InsideTableCell_FormatsSelectedText() + { + var table = Table(); + var doc = new EditorDocument([table]); + string before = Dump(doc); + var undo = new UndoManager(); + var ctx = Ctx(doc, Cell(0, 0, 0, 0, 1), Cell(0, 0, 0, 0, 2), undo); + + new ToggleBoldCommand().Execute(ctx); + + Assert.Equal("T['A','1'b/'B1';'A2'/'B2']", Dump(doc)); + // The selection stays on the same characters of the same cell. + Assert.Equal((0, 0), (ctx.Selection.Range.Start.CellRow, ctx.Selection.Range.Start.CellCol)); + Assert.Equal(1, DocumentEditor.GetAbsoluteCharOffset(doc, ctx.Selection.Range.Start)); + Assert.Equal(2, DocumentEditor.GetAbsoluteCharOffset(doc, ctx.Selection.Range.End)); + + // Toggling again turns bold off: the whole selection is already bold. + new ToggleBoldCommand().Execute(ctx); + Assert.Equal(before, Dump(doc)); + + undo.Undo(); + Assert.Equal("T['A','1'b/'B1';'A2'/'B2']", Dump(doc)); + undo.Undo(); + Assert.Equal(before, Dump(doc)); + undo.Redo(); + Assert.Equal("T['A','1'b/'B1';'A2'/'B2']", Dump(doc)); + } + + [Fact] + public void ToggleBold_AcrossTableCells_FormatsEveryCellInRange() + { + var table = Table(); + var doc = new EditorDocument([new Paragraph("x"), table]); + string before = Dump(doc); + var undo = new UndoManager(); + var ctx = Ctx(doc, Cell(1, 0, 1, 0, 1), Cell(1, 1, 0, 0, 1), undo); + + new ToggleBoldCommand().Execute(ctx); + + Assert.Equal("P['x'] | T['A1'/'B','1'b;'A'b,'2'/'B2']", Dump(doc)); + Assert.True(FormattingEngine.IsAppliedToEntireRange(doc, ctx.Selection.Range, s => s.IsBold)); + + undo.Undo(); + Assert.Equal(before, Dump(doc)); + undo.Redo(); + Assert.Equal("P['x'] | T['A1'/'B','1'b;'A'b,'2'/'B2']", Dump(doc)); + } + + [Fact] + public void ApplyTextStyle_FromParagraphIntoTable_FormatsParagraphAndCellsUpToEnd() + { + var table = Table(); + var doc = new EditorDocument([new Paragraph("xy"), table, new Paragraph("z")]); + + DocumentEditor.ApplyTextStyle(doc, new TextRange(new(0, 0, 1), Cell(1, 1, 0, 0, 1)), s => s.WithBold(true)); + + Assert.Equal("P['x','y'b] | T['A1'b/'B1'b;'A'b,'2'/'B2'] | P['z']", Dump(doc)); + } + + [Fact] + public void ApplyTextStyle_WholeTableBetweenParagraphs_FormatsAllCells() + { + var table = Table(); + var doc = new EditorDocument([new Paragraph("x"), table, new Paragraph("z")]); + + DocumentEditor.ApplyTextStyle(doc, new TextRange(new(0, 0, 0), new(2, 0, 1)), s => s.WithBold(true)); + + Assert.Equal("P['x'b] | T['A1'b/'B1'b;'A2'b/'B2'b] | P['z'b]", Dump(doc)); + } + + [Fact] + public void ApplyFontSize_InTableCell_IsReadBackByGetTextStyleForRange() + { + var table = Table(); + var doc = new EditorDocument([table]); + var range = new TextRange(Cell(0, 1, 1, 0, 0), Cell(0, 1, 1, 0, 2)); + + new FontFormattingEngine().ApplyFontSize(doc, range, 20); + + Assert.Equal(20, CellParagraph(table, 1, 1).Inlines.OfType().Single().Style.FontSize); + Assert.Equal(20, FontFormattingEngine.GetTextStyleForRange(doc, range)?.FontSize); + Assert.Equal(TextStyle.Default, CellParagraph(table, 1, 0).Inlines.OfType().Single().Style); + } + + // ── 4. Inserting text before an image ───────────────────────────────────── + + [Fact] + public void InsertText_AtStartOfImage_InsertsBeforeImage() + { + var p = new Paragraph(); + p.AddInline(new ImageInline("img.png")); + var doc = new EditorDocument([p]); + + var pos = DocumentEditor.InsertText(doc, new DocumentPosition(0, 0, 0), "ab"); + + Assert.Equal("P['ab',I(img.png)]", Dump(doc)); + Assert.Equal(new DocumentPosition(0, 0, 2), pos); + } + + [Fact] + public void InsertText_AtStartOfImageAfterRun_AppendsToPrecedingRun() + { + var p = new Paragraph("x"); + p.AddInline(new ImageInline("img.png")); + p.AppendRun("y"); + var doc = new EditorDocument([p]); + + var pos = DocumentEditor.InsertText(doc, new DocumentPosition(0, 1, 0), "ab"); + + Assert.Equal("P['xab',I(img.png),'y']", Dump(doc)); + Assert.Equal(new DocumentPosition(0, 0, 3), pos); + } + + [Fact] + public void InsertText_AtEndOfImage_StillInsertsAfterImage() + { + var p = new Paragraph(); + p.AddInline(new ImageInline("img.png")); + var doc = new EditorDocument([p]); + + DocumentEditor.InsertText(doc, new DocumentPosition(0, 0, 1), "ab"); + + Assert.Equal("P[I(img.png),'ab']", Dump(doc)); + } + + [Fact] + public void InsertTextAction_AtStartOfImage_UndoRemovesOnlyInsertedText() + { + var p = new Paragraph(); + p.AddInline(new ImageInline("img.png")); + var doc = new EditorDocument([p]); + var undo = new UndoManager(); + + undo.Push(new InsertTextAction(doc, new DocumentPosition(0, 0, 0), "ab")); + Assert.Equal("P['ab',I(img.png)]", Dump(doc)); + + undo.Undo(); + Assert.Equal("P[I(img.png)]", Dump(doc)); + undo.Redo(); + Assert.Equal("P['ab',I(img.png)]", Dump(doc)); + } +} From 6f9a49c11895d8167a5a8629abe9793a73bfa0d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:23:15 +0000 Subject: [PATCH 2/5] ci: compare changelog line numbers numerically awk compared the hunk line number (a string from substr) with the section bounds as strings, so an entry on line 40 was not counted as after line 9. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- .github/workflows/changelog.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index da9ff86..15bd0b3 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -56,7 +56,8 @@ jobs: # Count lines this PR adds inside that section (ignoring blank lines and rules). added=$(git diff -U0 "$base" "$HEAD_SHA" -- CHANGELOG.md | awk -v s="$start" -v e="$end" ' - /^@@/ { split($3, a, ","); line = substr(a[1], 2); next } + BEGIN { s += 0; e += 0 } + /^@@/ { split($3, a, ","); line = substr(a[1], 2) + 0; next } /^\+\+\+/ { next } /^\+/ { if (line > s && line < e && $0 !~ /^\+[[:space:]]*(---)?[[:space:]]*$/) n++; line++; next } END { print n + 0 }') From 240dd4465f0521b9f7f4bf8c024ebc8c743d9113 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:32:43 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20selec?= =?UTF-8?q?ted=20text=20includes=20hyperlinks=20and=20line=20breaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetSelectedText walked only the TextRun inlines of each paragraph, so in a cell holding "A", a link showing "B" and "C" copy returned "AC", cutting the linked text deleted it without copying it, and linking already-linked text with no display text produced an empty link. It now extracts text by character offsets, recursing into hyperlink children and adding a newline for each line break, so a partly selected link contributes only its selected characters. This applies to top-level paragraphs too, which had the same gap. HyperlinkEngine.InsertHyperlink also re-resolves its insertion point by character offset after deleting the selection: deleting a whole link between two runs merges them, which left the old inline index pointing past the merged run and put the new link after it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- CHANGELOG.md | 2 +- .../Document/DocumentEditor.cs | 71 ++++++++++++++----- .../Formatting/HyperlinkEngine.cs | 6 +- .../TableEditingFixesTests.cs | 67 +++++++++++++++++ 4 files changed, 126 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a34122..202b276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`SelectionChanged` after undo, redo and loading**: `MintedEditorView.Undo()` and `Redo()` raise `SelectionChanged` when they move the caret, and `LoadHtml`/`LoadMarkdown` raise it after resetting the caret. - **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`. -- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells, and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it. See [Tables](docs/tables.md#editing-cell-content). +- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells, and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string, and now includes the text of hyperlinks (only the selected part of a partly selected link) and a newline for each line break, which it used to drop everywhere; so cutting linked text copies it, and linking already-linked text keeps that text in place. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it. See [Tables](docs/tables.md#editing-cell-content). - **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. diff --git a/src/MintedTextEditor.Core/Document/DocumentEditor.cs b/src/MintedTextEditor.Core/Document/DocumentEditor.cs index ad7cce7..9f9a6ea 100644 --- a/src/MintedTextEditor.Core/Document/DocumentEditor.cs +++ b/src/MintedTextEditor.Core/Document/DocumentEditor.cs @@ -556,9 +556,7 @@ public static string GetSelectedText(Document document, TextRange range) { if (i > 0) sb.Append('\n'); var (para, from, to) = paragraphs[i]; - AppendRunText(sb, para, - from ?? new DocumentPosition(0, 0, 0), - to ?? new DocumentPosition(0, para.Inlines.Count - 1, int.MaxValue)); + AppendText(sb, para, from, to); } return sb.ToString(); } @@ -570,32 +568,69 @@ public static string GetSelectedText(Document document, TextRange range) if (document.Blocks[bi] is not Paragraph para) continue; - AppendRunText(sb, para, - bi == start.BlockIndex ? start : new DocumentPosition(bi, 0, 0), - bi == end.BlockIndex ? end : new DocumentPosition(bi, para.Inlines.Count - 1, int.MaxValue)); + AppendText(sb, para, + bi == start.BlockIndex ? start : null, + bi == end.BlockIndex ? end : null); } return sb.ToString(); } /// - /// Appends the text of the text runs of from - /// to (inline index and offset pairs within ). + /// Appends the text of from to + /// (positions within ; null: its start or end): the text of its runs, + /// including those inside hyperlinks, with a newline for each line break. Images add no text. + /// An inline that is only partly selected contributes only its selected characters. /// - private static void AppendRunText(System.Text.StringBuilder sb, Paragraph para, DocumentPosition from, DocumentPosition to) + private static void AppendText(System.Text.StringBuilder sb, Paragraph para, DocumentPosition? from, DocumentPosition? to) { - for (int ii = from.InlineIndex; ii <= to.InlineIndex && ii < para.Inlines.Count; ii++) - { - if (para.Inlines[ii] is not TextRun run) continue; + int fromOffset = from is { } f ? OffsetInParagraph(para, f) : 0; + int toOffset = to is { } t ? OffsetInParagraph(para, t) : int.MaxValue; + if (fromOffset < toOffset) + AppendText(sb, para.Inlines, 0, fromOffset, toOffset); + } - int startOffset = ii == from.InlineIndex ? from.Offset : 0; - int endOffset = ii == to.InlineIndex ? to.Offset : run.Text.Length; + /// + /// Appends the text of , which start at character , + /// that lies in [, ). Returns the offset after the last inline. + /// + private static int AppendText(System.Text.StringBuilder sb, IEnumerable inlines, int offset, int from, int to) + { + foreach (var inline in inlines) + { + if (inline is HyperlinkInline link) + { + offset = AppendText(sb, link.Children, offset, from, to); + continue; + } - startOffset = Math.Clamp(startOffset, 0, run.Text.Length); - endOffset = Math.Clamp(endOffset, 0, run.Text.Length); - if (startOffset < endOffset) - sb.Append(run.Text, startOffset, endOffset - startOffset); + int length = inline.Length; + int start = Math.Max(from, offset) - offset; + int end = Math.Min(to, offset + length) - offset; + if (start < end) + { + if (inline is TextRun run) + sb.Append(run.Text, start, end - start); + else if (inline is LineBreak) + sb.Append('\n'); + } + offset += length; } + return offset; + } + + /// + /// The character offset of 's inline index and offset within + /// , with the offset clamped to its inline. + /// + private static int OffsetInParagraph(Paragraph para, DocumentPosition position) + { + int offset = 0; + for (int i = 0; i < position.InlineIndex && i < para.Inlines.Count; i++) + offset += para.Inlines[i].Length; + if (position.InlineIndex >= 0 && position.InlineIndex < para.Inlines.Count) + offset += Math.Clamp(position.Offset, 0, para.Inlines[position.InlineIndex].Length); + return offset; } private static Paragraph GetParagraph(Document document, int blockIndex) diff --git a/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs b/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs index 18ed043..304970b 100644 --- a/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs +++ b/src/MintedTextEditor.Core/Formatting/HyperlinkEngine.cs @@ -57,7 +57,11 @@ public static HyperlinkInline InsertHyperlink( else { linkText = hasDisplayText ? displayText! : DocumentEditor.GetSelectedText(doc, range); - insertPos = DocumentEditor.DeleteRange(doc, range); + // The delete can remove and merge inlines (e.g. a whole link between two runs), so + // the start's inline index may be stale afterwards; re-resolve its character offset. + int startOffset = DocumentEditor.GetAbsoluteCharOffset(doc, range.Start); + var deletedAt = DocumentEditor.DeleteRange(doc, range); + insertPos = DocumentEditor.AbsoluteCharOffsetToPosition(doc, deletedAt, startOffset); } var hyperlink = new HyperlinkInline(url, title); diff --git a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs index 497fe3a..cbe03e0 100644 --- a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs +++ b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs @@ -1,6 +1,7 @@ using MintedTextEditor.Core.Commands; using MintedTextEditor.Core.Document; using MintedTextEditor.Core.Editing; +using MintedTextEditor.Core.Events; using MintedTextEditor.Core.Formatting; namespace MintedTextEditor.Core.Tests; @@ -174,6 +175,72 @@ public void GetSelectedText_InTableCells_ReturnsCellText() Assert.Equal("1\nA2\nB", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 1, 0, 1), Cell(0, 1, 1, 0, 1)))); } + /// A 1x1 table whose cell holds "A", a link showing "BD", "C", a line break and "E". + private static TableBlock TableWithLink() + { + var table = new TableBlock(1, 1); + var p = CellParagraph(table, 0, 0); + p.AppendRun("A"); + var link = new HyperlinkInline("https://x.y"); + link.AddChild(new TextRun("BD")); + p.AddInline(link); + p.AppendRun("C"); + p.AddInline(new LineBreak()); + p.AppendRun("E"); + return table; + } + + [Fact] + public void GetSelectedText_InTableCell_IncludesHyperlinkTextAndLineBreaks() + { + var doc = new EditorDocument([TableWithLink()]); + + Assert.Equal("ABDC\nE", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 0, 0, 0), Cell(0, 0, 0, 4, 1)))); + // Partly selected link: only its selected characters. + Assert.Equal("DC\nE", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 0, 1, 1), Cell(0, 0, 0, 4, 1)))); + Assert.Equal("B", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 0, 1, 0), Cell(0, 0, 0, 1, 1)))); + } + + [Fact] + public void GetSelectedText_TopLevelParagraph_IncludesHyperlinkText() + { + var p = new Paragraph("A"); + var link = new HyperlinkInline("https://x.y"); + link.AddChild(new TextRun("BD")); + p.AddInline(link); + p.AppendRun("C"); + var doc = new EditorDocument([p]); + + Assert.Equal("ABDC", DocumentEditor.GetSelectedText(doc, new TextRange(new(0, 0, 0), new(0, 2, 1)))); + Assert.Equal("DC", DocumentEditor.GetSelectedText(doc, new TextRange(new(0, 1, 1), new(0, 2, 1)))); + } + + [Fact] + public void CutCommand_LinkedTextInTableCell_CopiesIt() + { + var doc = new EditorDocument([TableWithLink()]); + var clipboard = new MockClipboardProvider(); + var sel = new Selection(); + sel.Set(Cell(0, 0, 0, 1, 0), Cell(0, 0, 0, 1, 2)); + var ctx = new EditorContext(doc, sel, new UndoManager(), new FormattingEngine(), new FontFormattingEngine(), clipboard); + + new CutCommand().Execute(ctx); + + Assert.Equal("BD", clipboard.Contents); + Assert.Equal("AC\nE", ((TableBlock)doc.Blocks[0]).GetCell(0, 0)!.GetText()); + } + + [Fact] + public void InsertHyperlink_OverLinkedTextInTableCell_KeepsText() + { + var doc = new EditorDocument([TableWithLink()]); + + var link = HyperlinkEngine.InsertHyperlink(doc, new TextRange(Cell(0, 0, 0, 1, 0), Cell(0, 0, 0, 1, 2)), "https://new.z"); + + Assert.Equal("BD", link.GetText()); + Assert.Equal("ABDC\nE", ((TableBlock)doc.Blocks[0]).GetCell(0, 0)!.GetText()); + } + // ── 3. Character formatting in table cells ──────────────────────────────── [Fact] From 2fe8f472bc386f3cf71da4ad469d71258f512afa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:33:26 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20skip?= =?UTF-8?q?=20cells=20hidden=20by=20a=20merge=20in=20table=20selections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetParagraphsInRange walked every cell between the start and end cell, including slots covered by another cell's RowSpan/ColumnSpan. MergeCells keeps those cells' blocks but layout hides them, so after merging the top row of a 2x2 table, a selection from (0,0) into (1,0) copied the hidden text of (0,1) and formatting changed it. The traversal now works out span ownership once per table with TableBlock.BuildSpanOwners(), as layout does, and skips covered slots (unless the range starts or ends in one). FormattingStateTracker had its own copy of the traversal with the same problem (hidden text could make the toolbar state look mixed); it now uses GetParagraphsInRange. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- CHANGELOG.md | 2 +- docs/tables.md | 2 +- .../Document/DocumentEditor.cs | 8 +++- .../Events/FormattingStateTracker.cs | 47 ++----------------- .../TableEditingFixesTests.cs | 45 ++++++++++++++++++ 5 files changed, 59 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 202b276..ea068c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`SelectionChanged` after undo, redo and loading**: `MintedEditorView.Undo()` and `Redo()` raise `SelectionChanged` when they move the caret, and `LoadHtml`/`LoadMarkdown` raise it after resetting the caret. - **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`. -- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells, and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string, and now includes the text of hyperlinks (only the selected part of a partly selected link) and a newline for each line break, which it used to drop everywhere; so cutting linked text copies it, and linking already-linked text keeps that text in place. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it. See [Tables](docs/tables.md#editing-cell-content). +- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells (cells hidden by a merge are skipped, here and in copy and the toolbar state), and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string, and now includes the text of hyperlinks (only the selected part of a partly selected link) and a newline for each line break, which it used to drop everywhere; so cutting linked text copies it, and linking already-linked text keeps that text in place. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it. See [Tables](docs/tables.md#editing-cell-content). - **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. diff --git a/docs/tables.md b/docs/tables.md index 67158d6..c800a2a 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -138,7 +138,7 @@ When the caret is inside a table cell: ## Editing Cell Content -- **Character formatting** (bold, italic, font, colours, clear formatting, …) applies to the selected text in a cell. A selection that spans several cells formats every cell between its start and end cell in reading order (row by row), and a selection that covers a whole table formats all of its cells. Each change is one undo step. +- **Character formatting** (bold, italic, font, colours, clear formatting, …) applies to the selected text in a cell. A selection that spans several cells formats every cell between its start and end cell in reading order (row by row), and a selection that covers a whole table formats all of its cells. Cells hidden by a merge are not part of a selection, so their text is neither formatted nor copied. Each change is one undo step. - **Hyperlinks** can be inserted at the caret or over the selected text in a cell. A link stays within one cell: when the selection runs into another cell, only its part in the start cell is linked. - **Deleting a selection** that starts in a paragraph before a table and ends in a paragraph after it deletes the table along with the selected text and joins the two paragraphs. Undo puts the table and the text back. diff --git a/src/MintedTextEditor.Core/Document/DocumentEditor.cs b/src/MintedTextEditor.Core/Document/DocumentEditor.cs index 9f9a6ea..459ac74 100644 --- a/src/MintedTextEditor.Core/Document/DocumentEditor.cs +++ b/src/MintedTextEditor.Core/Document/DocumentEditor.cs @@ -471,7 +471,9 @@ private static void ApplyTextStyleToParagraph(Paragraph paragraph, DocumentPosit /// range starts before the table) to its end cell (or the last cell when it ends after it). /// From and To are the range's endpoints when they lie in that paragraph, and null /// when the paragraph is covered from its start or to its end. A position in a cell refers to the - /// cell's first paragraph, so later paragraphs of the end cell are not included. + /// cell's first paragraph, so later paragraphs of the end cell are not included. Slots covered by a + /// merged cell's / are hidden, so + /// their cells are skipped (unless the range starts or ends in one). /// internal static List<(Paragraph Paragraph, DocumentPosition? From, DocumentPosition? To)> GetParagraphsInRange( Document document, TextRange range) @@ -493,6 +495,8 @@ private static void ApplyTextStyleToParagraph(Paragraph paragraph, DocumentPosit case TableBlock table: bool startsInTable = bi == start.BlockIndex && start.IsInTableCell; bool endsInTable = bi == end.BlockIndex && end.IsInTableCell; + // Slots covered by another cell's span are hidden (layout skips them too). + var owners = table.BuildSpanOwners(); for (int r = 0; r < table.Rows.Count; r++) { for (int c = 0; c < table.Rows[r].Cells.Count; c++) @@ -501,6 +505,8 @@ private static void ApplyTextStyleToParagraph(Paragraph paragraph, DocumentPosit if (endsInTable && (r, c).CompareTo((end.CellRow, end.CellCol)) > 0) continue; bool isStartCell = startsInTable && r == start.CellRow && c == start.CellCol; bool isEndCell = endsInTable && r == end.CellRow && c == end.CellCol; + bool covered = r < owners.Length && c < owners[r].Length && owners[r][c] != (r, c); + if (covered && !isStartCell && !isEndCell) continue; var blocks = table.Rows[r].Cells[c].Blocks; for (int k = 0; k < blocks.Count; k++) diff --git a/src/MintedTextEditor.Core/Events/FormattingStateTracker.cs b/src/MintedTextEditor.Core/Events/FormattingStateTracker.cs index 9056053..a8bcf6e 100644 --- a/src/MintedTextEditor.Core/Events/FormattingStateTracker.cs +++ b/src/MintedTextEditor.Core/Events/FormattingStateTracker.cs @@ -116,50 +116,13 @@ public static FormattingState Compute(Document.Document document, TextRange rang private static List CollectSelectedRunStyles(Document.Document document, TextRange range) { var styles = new List(); - var start = range.Start; - var end = range.End; - for (int bi = Math.Max(0, start.BlockIndex); bi <= end.BlockIndex && bi < document.Blocks.Count; bi++) + // Top-level paragraphs and the paragraphs of the visible table cells in the range. + foreach (var (paragraph, start, end) in DocumentEditor.GetParagraphsInRange(document, range)) { - switch (document.Blocks[bi]) - { - case Paragraph paragraph: - { - int from = bi == start.BlockIndex && !start.IsInTableCell - ? DocumentEditor.GetAbsoluteCharOffset(document, start) : 0; - int to = bi == end.BlockIndex && !end.IsInTableCell - ? DocumentEditor.GetAbsoluteCharOffset(document, end) : int.MaxValue; - CollectFromInlines(paragraph.Inlines, 0, from, to, styles); - break; - } - case TableBlock table: - { - bool startsInTable = bi == start.BlockIndex && start.IsInTableCell; - bool endsInTable = bi == end.BlockIndex && end.IsInTableCell; - for (int r = 0; r < table.Rows.Count; r++) - { - for (int c = 0; c < table.Rows[r].Cells.Count; c++) - { - bool isStartCell = startsInTable && r == start.CellRow && c == start.CellCol; - bool isEndCell = endsInTable && r == end.CellRow && c == end.CellCol; - if (startsInTable && (r, c).CompareTo((start.CellRow, start.CellCol)) < 0) continue; - if (endsInTable && (r, c).CompareTo((end.CellRow, end.CellCol)) > 0) continue; - - // Positions in a cell refer to its first paragraph. - var blocks = table.Rows[r].Cells[c].Blocks; - for (int k = 0; k < blocks.Count; k++) - { - if (blocks[k] is not Paragraph cellParagraph) continue; - if (isEndCell && k > 0) break; - int from = isStartCell && k == 0 ? DocumentEditor.GetAbsoluteCharOffset(document, start) : 0; - int to = isEndCell ? DocumentEditor.GetAbsoluteCharOffset(document, end) : int.MaxValue; - CollectFromInlines(cellParagraph.Inlines, 0, from, to, styles); - } - } - } - break; - } - } + int from = start is { } s ? DocumentEditor.GetAbsoluteCharOffset(document, s) : 0; + int to = end is { } e ? DocumentEditor.GetAbsoluteCharOffset(document, e) : int.MaxValue; + CollectFromInlines(paragraph.Inlines, 0, from, to, styles); } return styles; diff --git a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs index cbe03e0..63125d9 100644 --- a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs +++ b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs @@ -241,6 +241,51 @@ public void InsertHyperlink_OverLinkedTextInTableCell_KeepsText() Assert.Equal("ABDC\nE", ((TableBlock)doc.Blocks[0]).GetCell(0, 0)!.GetText()); } + // ── Merged cells: covered slots are hidden and not part of a selection ──── + + /// The with its top row merged into one cell; (0,1) keeps its hidden "B1". + private static TableBlock TableWithMergedTopRow() + { + var table = Table(); + TableEngine.MergeCells(table, 0, 0, 1, 2); + return table; + } + + [Fact] + public void GetSelectedText_AcrossMergedCell_SkipsCoveredSlot() + { + var doc = new EditorDocument([TableWithMergedTopRow()]); + + Assert.Equal("1\nA", DocumentEditor.GetSelectedText(doc, new TextRange(Cell(0, 0, 0, 0, 1), Cell(0, 1, 0, 0, 1)))); + } + + [Fact] + public void ApplyTextStyle_AcrossMergedCell_LeavesCoveredSlotUnchanged() + { + var table = TableWithMergedTopRow(); + var doc = new EditorDocument([table]); + var range = new TextRange(Cell(0, 0, 0, 0, 0), Cell(0, 1, 0, 0, 2)); + + DocumentEditor.ApplyTextStyle(doc, range, s => s.WithBold(true)); + + Assert.Equal("T['A1'b/'B1';'A2'b/'B2']", Dump(doc)); + Assert.True(FormattingEngine.IsAppliedToEntireRange(doc, range, s => s.IsBold)); + } + + [Fact] + public void FormattingState_AcrossMergedCell_IgnoresCoveredSlot() + { + var table = TableWithMergedTopRow(); + CellParagraph(table, 0, 0).Inlines.OfType().Single().Style = Bold; + CellParagraph(table, 1, 0).Inlines.OfType().Single().Style = Bold; + var doc = new EditorDocument([table]); + + // The hidden, non-bold "B1" must not make the visible all-bold selection look mixed. + var state = FormattingState.Compute(doc, new TextRange(Cell(0, 0, 0, 0, 0), Cell(0, 1, 0, 0, 2))); + + Assert.True(state.IsBold); + } + // ── 3. Character formatting in table cells ──────────────────────────────── [Fact] From d79cf863f0cef78f2abd180becc58cf936e00101 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:34:40 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20redo?= =?UTF-8?q?=20keeps=20text=20inserted=20before=20an=20image=20outside=20a?= =?UTF-8?q?=20preceding=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With [link("site"), image], an InsertTextAction at the image's offset 0 gives [link, "X", image]. Its start anchor is a paragraph character offset, which after undo resolves to the end of the link, and because EscapeHyperlinkBoundary is false the redo put "X" inside the link. ParagraphAnchor's keepHyperlinkStart option becomes keepHyperlinkSide: it still keeps a position recorded at the start of a link there, and now also resolves a position recorded outside any link to the start of the inline after a link's end (when that inline is not another link), via the new DocumentEditor.PreferAfterHyperlinkEnd. Only InsertTextAction's start anchor uses the option, so insertions before images, line breaks or runs replay on the same side of the link; a position recorded at a link's end still extends the link on redo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0197tHRWhTiagB1MTsDuPkoa --- CHANGELOG.md | 2 +- .../Document/DocumentEditor.cs | 22 ++++++++++ .../Editing/UndoableActions.cs | 24 +++++++---- .../TableEditingFixesTests.cs | 43 +++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea068c7..984da75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **`SelectionChanged` after undo, redo and loading**: `MintedEditorView.Undo()` and `Redo()` raise `SelectionChanged` when they move the caret, and `LoadHtml`/`LoadMarkdown` raise it after resetting the caret. - **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`. -- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells (cells hidden by a merge are skipped, here and in copy and the toolbar state), and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string, and now includes the text of hyperlinks (only the selected part of a partly selected link) and a newline for each line break, which it used to drop everywhere; so cutting linked text copies it, and linking already-linked text keeps that text in place. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it. See [Tables](docs/tables.md#editing-cell-content). +- **Editing in and around tables**: character formatting (toolbar, shortcuts, commands, `DocumentEditor.ApplyTextStyle` and the formatting engines) now applies to selected text in table cells, including selections spanning several cells (cells hidden by a merge are skipped, here and in copy and the toolbar state), and the toolbar toggles and undo work there; previously nothing changed but an undo step was recorded. Inserting a hyperlink in a table cell no longer throws, and is undoable. `DocumentEditor.GetSelectedText` (copy, cut) returns the text selected in table cells instead of an empty string, and now includes the text of hyperlinks (only the selected part of a partly selected link) and a newline for each line break, which it used to drop everywhere; so cutting linked text copies it, and linking already-linked text keeps that text in place. Undoing the deletion of a selection that runs across a table between two paragraphs (which deletes the table) now restores the table and both paragraphs, and undoing a deletion inside a cell restores the cell's text. `DocumentEditor.InsertText` at the start of an image now inserts the text before the image instead of after it, and redoing such an insertion (or one before a line break) right after a hyperlink keeps the text outside the link. See [Tables](docs/tables.md#editing-cell-content). - **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. diff --git a/src/MintedTextEditor.Core/Document/DocumentEditor.cs b/src/MintedTextEditor.Core/Document/DocumentEditor.cs index 459ac74..e4b6d7e 100644 --- a/src/MintedTextEditor.Core/Document/DocumentEditor.cs +++ b/src/MintedTextEditor.Core/Document/DocumentEditor.cs @@ -1056,6 +1056,28 @@ internal static DocumentPosition PreferFollowingHyperlink(Document document, Doc return position.With(i + 1, 0); } + /// + /// When is at the end of a hyperlink that is followed by an inline other + /// than a hyperlink (a run, an image, a line break), returns the same place expressed as the start of + /// that inline, so text inserted there stays outside the link. Otherwise returns + /// unchanged. + /// + internal static DocumentPosition PreferAfterHyperlinkEnd(Document document, DocumentPosition position) + { + Paragraph paragraph; + try { paragraph = GetParagraph(document, position); } + catch (Exception ex) when (ex is ArgumentOutOfRangeException or InvalidOperationException) { return position; } + + int i = position.InlineIndex; + if (i < 0 || i + 1 >= paragraph.Inlines.Count) + return position; + if (paragraph.Inlines[i] is not HyperlinkInline link || position.Offset < link.Length) + return position; + if (paragraph.Inlines[i + 1] is HyperlinkInline) + return position; + return position.With(i + 1, 0); + } + /// Returns the hyperlink at , or null when it is not in one. internal static HyperlinkInline? GetHyperlinkAt(Document document, DocumentPosition position) { diff --git a/src/MintedTextEditor.Core/Editing/UndoableActions.cs b/src/MintedTextEditor.Core/Editing/UndoableActions.cs index ab1cd77..6feee4a 100644 --- a/src/MintedTextEditor.Core/Editing/UndoableActions.cs +++ b/src/MintedTextEditor.Core/Editing/UndoableActions.cs @@ -34,20 +34,24 @@ internal readonly struct ParagraphAnchor private readonly int _offset; private readonly bool _inParagraph; private readonly bool _atHyperlinkStart; + private readonly bool _outsideHyperlink; /// The document containing . /// The position to anchor. - /// - /// When true and is at the start of a hyperlink, - /// returns the start of the hyperlink rather than the end of the inline before it. + /// + /// When true, keeps the position on the same side of a hyperlink boundary: + /// a position at the start of a hyperlink resolves to the start of the hyperlink rather than the + /// end of the inline before it, and a position outside any hyperlink (e.g. at the start of an image + /// that follows a link) resolves to the start of the inline after the link rather than the link's end. /// - public ParagraphAnchor(Document.Document document, DocumentPosition position, bool keepHyperlinkStart = false) + public ParagraphAnchor(Document.Document document, DocumentPosition position, bool keepHyperlinkSide = false) { _context = position; _inParagraph = IsParagraphPosition(document, position); _offset = _inParagraph ? DocumentEditor.GetAbsoluteCharOffset(document, position) : position.Offset; - _atHyperlinkStart = _inParagraph && keepHyperlinkStart && position.Offset == 0 - && DocumentEditor.GetHyperlinkAt(document, position) is not null; + bool inHyperlink = _inParagraph && DocumentEditor.GetHyperlinkAt(document, position) is not null; + _atHyperlinkStart = keepHyperlinkSide && inHyperlink && position.Offset == 0; + _outsideHyperlink = keepHyperlinkSide && _inParagraph && !inHyperlink; } public DocumentPosition Resolve(Document.Document document) @@ -55,7 +59,11 @@ public DocumentPosition Resolve(Document.Document document) if (!_inParagraph || !IsParagraphPosition(document, _context)) return _context; var position = DocumentEditor.AbsoluteCharOffsetToPosition(document, _context, _offset); - return _atHyperlinkStart ? DocumentEditor.PreferFollowingHyperlink(document, position) : position; + if (_atHyperlinkStart) + return DocumentEditor.PreferFollowingHyperlink(document, position); + if (_outsideHyperlink) + return DocumentEditor.PreferAfterHyperlinkEnd(document, position); + return position; } public bool SameAs(ParagraphAnchor other) @@ -236,7 +244,7 @@ public InsertTextAction(Document.Document document, DocumentPosition position, s public DocumentPosition Execute() { var start = _startAnchor is { } anchor ? anchor.Resolve(_document) : _position; - _startAnchor ??= new ParagraphAnchor(_document, start, keepHyperlinkStart: true); + _startAnchor ??= new ParagraphAnchor(_document, start, keepHyperlinkSide: true); _resultPosition = DocumentEditor.InsertText(_document, start, _text, _style, EscapeHyperlinkBoundary); _endAnchor = new ParagraphAnchor(_document, _resultPosition); return _resultPosition; diff --git a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs index 63125d9..df1cae0 100644 --- a/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs +++ b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs @@ -402,6 +402,49 @@ public void InsertText_AtStartOfImageAfterRun_AppendsToPrecedingRun() Assert.Equal(new DocumentPosition(0, 0, 3), pos); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InsertTextAction_BeforeImageOrLineBreakAfterLink_RedoKeepsTextOutsideLink(bool lineBreak) + { + var p = new Paragraph(); + var link = new HyperlinkInline("https://x.y"); + link.AddChild(new TextRun("site")); + p.AddInline(link); + p.AddInline(lineBreak ? new LineBreak() : new ImageInline("img.png")); + var doc = new EditorDocument([p]); + string after = lineBreak ? "P[L(https://x.y:'site'),'X',LineBreak]" : "P[L(https://x.y:'site'),'X',I(img.png)]"; + var undo = new UndoManager(); + + undo.Push(new InsertTextAction(doc, new DocumentPosition(0, 1, 0), "X")); + Assert.Equal(after, Dump(doc)); + + undo.Undo(); + undo.Redo(); + Assert.Equal(after, Dump(doc)); + undo.Undo(); + Assert.Equal(lineBreak ? "P[L(https://x.y:'site'),LineBreak]" : "P[L(https://x.y:'site'),I(img.png)]", Dump(doc)); + } + + [Fact] + public void InsertTextAction_AtLinkEnd_RedoStillExtendsLink() + { + // A position inside the link (at its end) keeps inserting into the link on redo. + var p = new Paragraph(); + var link = new HyperlinkInline("https://x.y"); + link.AddChild(new TextRun("site")); + p.AddInline(link); + p.AddInline(new ImageInline("img.png")); + var doc = new EditorDocument([p]); + var undo = new UndoManager(); + + undo.Push(new InsertTextAction(doc, new DocumentPosition(0, 0, 4), "X")); + undo.Undo(); + undo.Redo(); + + Assert.Equal("P[L(https://x.y:'siteX'),I(img.png)]", Dump(doc)); + } + [Fact] public void InsertText_AtEndOfImage_StillInsertsAfterImage() {