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 }')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2ef846..2123305 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +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, 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.
- **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).
diff --git a/docs/tables.md b/docs/tables.md
index 7028f23..c800a2a 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. 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.
+
## 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..e4b6d7e 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,124 @@ 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;
- var start = range.Start;
- var end = range.End;
+ foreach (var (paragraph, from, to) in GetParagraphsInRange(document, range))
+ ApplyTextStyleToParagraph(paragraph, from, to, transform);
- for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < document.Blocks.Count; bi++)
+ 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--)
{
- var paragraph = document.Blocks[bi] as Paragraph;
- if (paragraph == null) continue;
+ 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;
- // 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 (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)
{
- if (paragraph.Inlines[ii] is not TextRun run) continue;
+ 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);
+ }
+ }
+ }
- bool isFirst = bi == start.BlockIndex && ii == start.InlineIndex;
- bool isLast = bi == end.BlockIndex && ii == end.InlineIndex;
+ ///
+ /// 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. 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)
+ {
+ var result = new List<(Paragraph, DocumentPosition?, DocumentPosition?)>();
+ var start = range.Start;
+ var end = range.End;
- 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);
+ for (int bi = Math.Max(0, start.BlockIndex); bi <= end.BlockIndex && bi < document.Blocks.Count; bi++)
+ {
+ switch (document.Blocks[bi])
+ {
+ case Paragraph paragraph:
+ result.Add((paragraph,
+ bi == start.BlockIndex && !start.IsInTableCell ? start : null,
+ bi == end.BlockIndex && !end.IsInTableCell ? end : null));
+ break;
+
+ 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++)
+ {
+ 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;
+ 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++)
+ {
+ 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 +554,19 @@ 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];
+ AppendText(sb, para, from, to);
+ }
+ return sb.ToString();
+ }
+
for (int bi = start.BlockIndex; bi <= end.BlockIndex && bi < document.Blocks.Count; bi++)
{
if (bi > start.BlockIndex)
@@ -485,24 +574,69 @@ 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;
+ AppendText(sb, para,
+ bi == start.BlockIndex ? start : null,
+ bi == end.BlockIndex ? end : null);
+ }
- for (int ii = startInline; ii <= endInline && ii < para.Inlines.Count; ii++)
- {
- if (para.Inlines[ii] is not TextRun run) continue;
+ return sb.ToString();
+ }
- int startOffset = bi == start.BlockIndex && ii == startInline ? start.Offset : 0;
- int endOffset = bi == end.BlockIndex && ii == endInline ? end.Offset : run.Text.Length;
+ ///
+ /// 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 AppendText(System.Text.StringBuilder sb, Paragraph para, DocumentPosition? from, DocumentPosition? to)
+ {
+ 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);
+ }
- 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);
+ ///
+ /// 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;
}
+
+ 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;
+ }
- return sb.ToString();
+ ///
+ /// 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)
@@ -516,7 +650,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 +970,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);
@@ -926,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 af5698b..6feee4a 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)
@@ -31,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)
@@ -52,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)
@@ -115,26 +126,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;
}
@@ -202,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;
@@ -244,8 +286,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 +296,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 +314,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 +323,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 +468,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 +507,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 +923,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 +954,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/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/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs
index cdd7eea..0862fb9 100644
--- a/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs
+++ b/src/MintedTextEditor.Core/Formatting/FontFormattingEngine.cs
@@ -117,17 +117,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;
@@ -153,16 +149,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 061323f..db6019c 100644
--- a/src/MintedTextEditor.Core/Formatting/FormattingEngine.cs
+++ b/src/MintedTextEditor.Core/Formatting/FormattingEngine.cs
@@ -177,18 +177,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))
@@ -199,22 +196,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..304970b 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;
@@ -51,14 +57,18 @@ 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);
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 +186,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..df1cae0
--- /dev/null
+++ b/tests/MintedTextEditor.Core.Tests/TableEditingFixesTests.cs
@@ -0,0 +1,476 @@
+using MintedTextEditor.Core.Commands;
+using MintedTextEditor.Core.Document;
+using MintedTextEditor.Core.Editing;
+using MintedTextEditor.Core.Events;
+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))));
+ }
+
+ /// 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());
+ }
+
+ // ── 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]
+ 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);
+ }
+
+ [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()
+ {
+ 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));
+ }
+}