diff --git a/CHANGELOG.md b/CHANGELOG.md
index 304119c..e8183ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ and follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### 표 행·열 명령과 단축키 표 공개 — 상류 PR #48·#49 백포트 (2026-09-24)
+
+**공개 API 추가**: `RichEditor.InsertTableRow`·`DeleteTableRow`·`InsertTableColumn`·`DeleteTableColumn`(표·인덱스 지정),
+`InsertRowAbove`·`InsertRowBelow`·`DeleteRow`·`InsertColumnLeft`·`InsertColumnRight`·`DeleteColumn`(캐럿 기준),
+`RichEditorShortcuts`(`All`·`Display`), `RichEditorShortcutId`, `RichEditorShortcut`.
+
+- **표 행·열 편집을 호스트가 부를 수 있다.** 전에는 우클릭 메뉴로만 닿아, 자체 툴바를 만드는 호스트는
+ `TableBlock`을 직접 고쳐야 했고 그러면 되돌리기 체크포인트·부모 배선·레이아웃 무효화를 건너뛰었다.
+ 호출 하나가 되돌리기 한 단계이고, 바뀐 게 있는지를 돌려준다. 읽기 전용, 범위 밖 인덱스, **다른 문서의 표**,
+ 표의 마지막 행·열은 `false`이고 아무것도 바꾸지 않는다. "아래"·"오른쪽"은 메뉴와 같이 병합 영역 뒤다.
+ 중첩 표·인라인 표도 된다. `AllowTables`는 보지 않는다(표를 **만드는** 것을 막는 플래그).
+- **단축키 표를 공개한다.** 호스트가 "Ctrl+B"를 편집기가 못 보는 곳에 다시 적지 않고 `RichEditorShortcuts.All`·
+ `Display(id)`로 읽는다. `All`은 읽기 전용 뷰라 호스트가 편집기 자신의 키 핸들러가 맞추는 표를 고칠 수 없다.
+ 타입 이름은 `ShortcutId`/`ShortcutSpec`에서 `RichEditorShortcutId`/`RichEditorShortcut`로(내부였으니 호환 영향 없음).
+ enum 값의 순서는 이 패키지 것이고 상류와 다르다. 상류의 `Gesture(id)`(Avalonia `KeyGesture`)는 WinUI 메뉴가
+ 힌트를 문자열로 받으므로 옮기지 않았다. `TryMatch`는 상류처럼 내부로 남긴다.
+
### 페이지 여백 + 상류 PR #50·#52 백포트 (2026-09-24)
**공개 API 추가**: `PageMargins`(mm 단위 네 변), `PageSetup.Margin`·`DefaultMargin`·`DipsPerMm`·`PaperMillimetres`,
diff --git a/Project_Roadmap.md b/Project_Roadmap.md
index 3bc956d..b02627f 100644
--- a/Project_Roadmap.md
+++ b/Project_Roadmap.md
@@ -65,7 +65,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조. 라운드34(상류 PR #53)는 2026-09-23 옮김(아래 절).
PR #50·#52(페이지 여백·여백 후속)는 2026-09-24 옮김 — CHANGELOG `[Unreleased]`. 목록에 있던 "그림 손잡이 vs 열 경계
우선순위"와 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`은 이미 들어와 있었다(`ControlImageResizeTests`·
- `ControlCaretFormatTests`). **남은 것**: 상류 PR #48(표 행·열 공개 API)·#49(단축키 표 공개) 대조.
+ `ControlCaretFormatTests`). PR #48(표 행·열 공개 API)·#49(단축키 표 공개)도 2026-09-24 옮김 — 상류 PR #53까지
+ 대조 끝. (`Gesture(id)`는 Avalonia 전용이라 뺐다. enum 순서는 포트 것 유지.)
4. 아래 "알려진 한계"의 미수정 항목.
### 상류 라운드34 백포트 (2026-09-23) — 테스트 832 → 862, 결함 14(보안 2 포함) + 동작 변경 3, 반증 완료
@@ -190,7 +191,6 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
PBKDF2는 앵커하지 말 것), 평문 `.flow`와 공존하는 외부 봉투, 분실 시 복구 불가 경고, 편집 중 평문이
UI/임시파일로 새지 않게. **어중간하게 넣어 "암호화됐다"는 잘못된 안심을 주는 건 금지**
(우회로: 7-Zip AES / BitLocker·VeraCrypt로 감싸면 된다).
-- 표 행/열 조작의 에디터 레벨 공개 API — 상류와 공유하는 백로그.
- **미측정 성능 후보 2건**(옛 `implementation_plan.md`에서 살아남은 것 — 그 파일은 2026-09-20에 지웠다.
나머지 제안은 이미 반영됐거나(`FindCell`은 `Parent` 기반) 근거가 없었다):
① `EvictLayouts()`가 캐시를 **전부** 비운다 — 바로 위 주석은 "가장 오래된 것만"이라고 말한다(주석/코드 불일치).
diff --git a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
index 6a205f4..bc9f93b 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
@@ -278,10 +278,10 @@ internal void BuildTextMenu(MenuFlyout menu, bool hasSel, string? linkUri)
// The four toggles are CHECK items reflecting the caret, like the slim menu's: as plain items
// this, the fuller menu, was the one that could not show whether bold was already on.
Item(Sub(Loc("CharacterFormat"), RichEditorIcon.CharacterFormat,
- BulletToggle(Loc("Bold"), fmt.Bold, ToggleBold, RichEditorShortcuts.Display(ShortcutId.Bold), RichEditorIcon.Bold),
- BulletToggle(Loc("Italic"), fmt.Italic, ToggleItalic, RichEditorShortcuts.Display(ShortcutId.Italic), RichEditorIcon.Italic),
- BulletToggle(Loc("Underline"), fmt.Underline, ToggleUnderline, RichEditorShortcuts.Display(ShortcutId.Underline), RichEditorIcon.Underline),
- BulletToggle(Loc("Strikethrough"), fmt.Strike, ToggleStrikethrough, RichEditorShortcuts.Display(ShortcutId.Strikethrough), RichEditorIcon.Strikethrough),
+ BulletToggle(Loc("Bold"), fmt.Bold, ToggleBold, RichEditorShortcuts.Display(RichEditorShortcutId.Bold), RichEditorIcon.Bold),
+ BulletToggle(Loc("Italic"), fmt.Italic, ToggleItalic, RichEditorShortcuts.Display(RichEditorShortcutId.Italic), RichEditorIcon.Italic),
+ BulletToggle(Loc("Underline"), fmt.Underline, ToggleUnderline, RichEditorShortcuts.Display(RichEditorShortcutId.Underline), RichEditorIcon.Underline),
+ BulletToggle(Loc("Strikethrough"), fmt.Strike, ToggleStrikethrough, RichEditorShortcuts.Display(RichEditorShortcutId.Strikethrough), RichEditorIcon.Strikethrough),
Sep(),
Mi(Loc("FontSizeIncrease"), IncreaseFontSize, true, RichEditorIcon.FontSizeIncrease, "Ctrl+Shift+."),
Mi(Loc("FontSizeDecrease"), DecreaseFontSize, true, RichEditorIcon.FontSizeDecrease, "Ctrl+Shift+,"),
@@ -293,13 +293,13 @@ internal void BuildTextMenu(MenuFlyout menu, bool hasSel, string? linkUri)
// ── 목록 (list) — promoted to top level ──
Item(Sub(Loc("List"), RichEditorIcon.BulletList,
- BulletToggle(Loc("BulletList"), fmt.List == ListKind.Bullet, ToggleBullet, RichEditorShortcuts.Display(ShortcutId.BulletList)),
+ BulletToggle(Loc("BulletList"), fmt.List == ListKind.Bullet, ToggleBullet, RichEditorShortcuts.Display(RichEditorShortcutId.BulletList)),
// Style submenus list STYLES only; a list is turned off by its own toggle (which clears the whole
// list state). The labelled "목록 제거" was a duplicate door and went (user decision, 2026-09-14).
Sub(Loc("BulletStyle"), null,
Mi("•", () => SetListStyle(ListMarkerStyle.Disc)), Mi("◦", () => SetListStyle(ListMarkerStyle.Circle)),
Mi("▪", () => SetListStyle(ListMarkerStyle.Square)), Mi("–", () => SetListStyle(ListMarkerStyle.Dash))),
- BulletToggle(Loc("NumberedList"), fmt.List == ListKind.Ordered, ToggleNumbering, RichEditorShortcuts.Display(ShortcutId.NumberedList)),
+ BulletToggle(Loc("NumberedList"), fmt.List == ListKind.Ordered, ToggleNumbering, RichEditorShortcuts.Display(RichEditorShortcutId.NumberedList)),
Sub(Loc("NumberStyle"), null,
Mi("1.", () => SetListStyle(ListMarkerStyle.Decimal)), Mi("1)", () => SetListStyle(ListMarkerStyle.DecimalParen)),
Mi("a)", () => SetListStyle(ListMarkerStyle.LowerAlpha)), Mi("A)", () => SetListStyle(ListMarkerStyle.UpperAlpha)),
@@ -331,12 +331,12 @@ internal void BuildTextMenu(MenuFlyout menu, bool hasSel, string? linkUri)
{
// Disabled for a link it would refuse to launch (see IsLaunchableLink) rather than silently doing nothing.
Item(Mi(Loc("OpenLink"), () => _ = OpenLinkAtCaretAsync(), IsLaunchableLink(linkUri, out _), RichEditorIcon.OpenLink));
- Item(Mi(Loc("EditLink"), () => _ = EditHyperlinkAsync(), true, RichEditorIcon.EditLink, RichEditorShortcuts.Display(ShortcutId.InsertLink)));
+ Item(Mi(Loc("EditLink"), () => _ = EditHyperlinkAsync(), true, RichEditorIcon.EditLink, RichEditorShortcuts.Display(RichEditorShortcutId.InsertLink)));
Item(Mi(Loc("RemoveLink"), () => SetHyperlink(null), true, RichEditorIcon.RemoveLink));
}
else
// Enabled without a selection too: the link applies to the caret word (ApplyStyleToSelection).
- Item(Mi(Loc("InsertLink"), () => _ = EditHyperlinkAsync(), true, RichEditorIcon.InsertLink, RichEditorShortcuts.Display(ShortcutId.InsertLink)));
+ Item(Mi(Loc("InsertLink"), () => _ = EditHyperlinkAsync(), true, RichEditorIcon.InsertLink, RichEditorShortcuts.Display(RichEditorShortcutId.InsertLink)));
Item(Sep());
Item(Mi(Loc("SelectAll"), SelectAll, true, RichEditorIcon.SelectAll, "Ctrl+A"));
@@ -362,7 +362,7 @@ internal void BuildTextMenu(MenuFlyout menu, bool hasSel, string? linkUri)
// Flattens the old 문단 ▸ 정렬 ▸ / 여백 nesting into one level, HWP-style.
private MenuFlyoutSubItem BuildParagraphFormatSub(CaretFormat fmt)
{
- RadioMenuFlyoutItem Align(string key, TextAlignment a, ShortcutId sc)
+ RadioMenuFlyoutItem Align(string key, TextAlignment a, RichEditorShortcutId sc)
{
var ri = new RadioMenuFlyoutItem { Text = Loc(key), GroupName = "ctxAlign", IsChecked = fmt.Align == a, FontSize = MenuFontSize };
ri.KeyboardAcceleratorTextOverride = RichEditorShortcuts.Display(sc);
@@ -370,13 +370,13 @@ RadioMenuFlyoutItem Align(string key, TextAlignment a, ShortcutId sc)
return ri;
}
var sub = new MenuFlyoutSubItem { Text = Loc("ParagraphFormat"), FontSize = MenuFontSize };
- sub.Items.Add(Align("AlignLeft", TextAlignment.Left, ShortcutId.AlignLeft));
- sub.Items.Add(Align("AlignCenter", TextAlignment.Center, ShortcutId.AlignCenter));
- sub.Items.Add(Align("AlignRight", TextAlignment.Right, ShortcutId.AlignRight));
- sub.Items.Add(Align("AlignJustify", TextAlignment.Justify, ShortcutId.AlignJustify));
+ sub.Items.Add(Align("AlignLeft", TextAlignment.Left, RichEditorShortcutId.AlignLeft));
+ sub.Items.Add(Align("AlignCenter", TextAlignment.Center, RichEditorShortcutId.AlignCenter));
+ sub.Items.Add(Align("AlignRight", TextAlignment.Right, RichEditorShortcutId.AlignRight));
+ sub.Items.Add(Align("AlignJustify", TextAlignment.Justify, RichEditorShortcutId.AlignJustify));
sub.Items.Add(Sep());
- sub.Items.Add(Mi(Loc("IndentIncrease"), () => Indent(20), true, RichEditorIcon.IndentIncrease, RichEditorShortcuts.Display(ShortcutId.IndentIncrease)));
- sub.Items.Add(Mi(Loc("IndentDecrease"), () => Indent(-20), true, RichEditorIcon.IndentDecrease, RichEditorShortcuts.Display(ShortcutId.IndentDecrease)));
+ sub.Items.Add(Mi(Loc("IndentIncrease"), () => Indent(20), true, RichEditorIcon.IndentIncrease, RichEditorShortcuts.Display(RichEditorShortcutId.IndentIncrease)));
+ sub.Items.Add(Mi(Loc("IndentDecrease"), () => Indent(-20), true, RichEditorIcon.IndentDecrease, RichEditorShortcuts.Display(RichEditorShortcutId.IndentDecrease)));
// Margin (top-level paragraphs only — cell paragraphs lay out inside the cell).
if (_caret.Paragraph is { } mp && Document != null && Document.Blocks.IndexOf(mp) >= 0)
{
@@ -403,7 +403,7 @@ private RadioMenuFlyoutItem HeadingItem(string text, int level, int current)
{
var ri = new RadioMenuFlyoutItem { Text = text, GroupName = "ctxHeading", IsChecked = current == level, FontSize = MenuFontSize };
// Heading1..6 are consecutive enum values; level 0 = body text.
- var sc = level == 0 ? ShortcutId.BodyText : (ShortcutId)((int)ShortcutId.Heading1 + level - 1);
+ var sc = level == 0 ? RichEditorShortcutId.BodyText : (RichEditorShortcutId)((int)RichEditorShortcutId.Heading1 + level - 1);
ri.KeyboardAcceleratorTextOverride = RichEditorShortcuts.Display(sc);
ri.Click += (_, _) => SetHeading(level);
return ri;
@@ -707,7 +707,7 @@ private MenuFlyoutItem SelectCellItem(TableBlock tb, int r, int c)
{
bool onCell = r >= 0 && c >= 0;
return Mi(Loc("SelectCell"), () => { if (onCell) { var (ar, ac) = tb.AnchorOf(r, c); SelectCellAsBlock(tb.Cells[ar][ac]); } },
- onCell, null, RichEditorShortcuts.Display(ShortcutId.SelectCell));
+ onCell, null, RichEditorShortcuts.Display(RichEditorShortcutId.SelectCell));
}
// Deleting `tb` from a menu: an inline table leaves its host line (DeleteTable removes blocks only).
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Input.cs b/src/WinUIRichEditor/Controls/RichEditor.Input.cs
index 6654e80..d32a6e8 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Input.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Input.cs
@@ -940,48 +940,48 @@ private bool TryCellBlockKey(VirtualKey key, bool shift, bool ctrl, bool alt)
// Runs a matched command shortcut. Copy / Select All work read-only; everything else is editing and is
// gated by IsReadOnly. Object-selection Copy/Cut is handled earlier in OnEditorKeyDown.
- private void RunShortcut(ShortcutId id)
+ private void RunShortcut(RichEditorShortcutId id)
{
switch (id)
{
- case ShortcutId.Copy: _ = CopyAsync(); return;
- case ShortcutId.SelectAll: SelectAll(); return;
- case ShortcutId.Find: if (AllowFindReplace) RaiseFindRequested(false); return; // works read-only
+ case RichEditorShortcutId.Copy: _ = CopyAsync(); return;
+ case RichEditorShortcutId.SelectAll: SelectAll(); return;
+ case RichEditorShortcutId.Find: if (AllowFindReplace) RaiseFindRequested(false); return; // works read-only
}
if (IsReadOnly) return;
switch (id)
{
- case ShortcutId.FindReplace: if (AllowFindReplace) RaiseFindRequested(true); break;
- case ShortcutId.InsertLink: _ = EditHyperlinkAsync(); break;
- case ShortcutId.Cut: _ = CutAsync(); break;
- case ShortcutId.Paste: _ = PasteAsync(false); break;
- case ShortcutId.PastePlain: _ = PasteAsync(true); break;
- case ShortcutId.Undo: Undo(); break;
- case ShortcutId.Redo: Redo(); break;
- case ShortcutId.Bold: ToggleBold(); break;
- case ShortcutId.Italic: ToggleItalic(); break;
- case ShortcutId.Underline: ToggleUnderline(); break;
- case ShortcutId.Strikethrough: ToggleStrikethrough(); break;
- case ShortcutId.FontLarger: IncreaseFontSize(); break;
- case ShortcutId.FontSmaller: DecreaseFontSize(); break;
- case ShortcutId.IndentIncrease: Indent(20); break;
- case ShortcutId.IndentDecrease: Indent(-20); break;
- case ShortcutId.AlignLeft: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Left); break;
- case ShortcutId.AlignCenter: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Center); break;
- case ShortcutId.AlignRight: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Right); break;
- case ShortcutId.AlignJustify: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Justify); break;
- case ShortcutId.Heading1: SetHeading(1); break;
- case ShortcutId.Heading2: SetHeading(2); break;
- case ShortcutId.Heading3: SetHeading(3); break;
- case ShortcutId.Heading4: SetHeading(4); break;
- case ShortcutId.Heading5: SetHeading(5); break;
- case ShortcutId.Heading6: SetHeading(6); break;
- case ShortcutId.BodyText: SetHeading(0); break;
- case ShortcutId.BulletList: ToggleBullet(); break;
- case ShortcutId.NumberedList: ToggleNumbering(); break;
- case ShortcutId.LineSpacingSingle: SetLineSpacing(1.0); break;
- case ShortcutId.LineSpacingOneHalf: SetLineSpacing(1.5); break;
- case ShortcutId.LineSpacingDouble: SetLineSpacing(2.0); break;
+ case RichEditorShortcutId.FindReplace: if (AllowFindReplace) RaiseFindRequested(true); break;
+ case RichEditorShortcutId.InsertLink: _ = EditHyperlinkAsync(); break;
+ case RichEditorShortcutId.Cut: _ = CutAsync(); break;
+ case RichEditorShortcutId.Paste: _ = PasteAsync(false); break;
+ case RichEditorShortcutId.PastePlain: _ = PasteAsync(true); break;
+ case RichEditorShortcutId.Undo: Undo(); break;
+ case RichEditorShortcutId.Redo: Redo(); break;
+ case RichEditorShortcutId.Bold: ToggleBold(); break;
+ case RichEditorShortcutId.Italic: ToggleItalic(); break;
+ case RichEditorShortcutId.Underline: ToggleUnderline(); break;
+ case RichEditorShortcutId.Strikethrough: ToggleStrikethrough(); break;
+ case RichEditorShortcutId.FontLarger: IncreaseFontSize(); break;
+ case RichEditorShortcutId.FontSmaller: DecreaseFontSize(); break;
+ case RichEditorShortcutId.IndentIncrease: Indent(20); break;
+ case RichEditorShortcutId.IndentDecrease: Indent(-20); break;
+ case RichEditorShortcutId.AlignLeft: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Left); break;
+ case RichEditorShortcutId.AlignCenter: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Center); break;
+ case RichEditorShortcutId.AlignRight: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Right); break;
+ case RichEditorShortcutId.AlignJustify: SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Justify); break;
+ case RichEditorShortcutId.Heading1: SetHeading(1); break;
+ case RichEditorShortcutId.Heading2: SetHeading(2); break;
+ case RichEditorShortcutId.Heading3: SetHeading(3); break;
+ case RichEditorShortcutId.Heading4: SetHeading(4); break;
+ case RichEditorShortcutId.Heading5: SetHeading(5); break;
+ case RichEditorShortcutId.Heading6: SetHeading(6); break;
+ case RichEditorShortcutId.BodyText: SetHeading(0); break;
+ case RichEditorShortcutId.BulletList: ToggleBullet(); break;
+ case RichEditorShortcutId.NumberedList: ToggleNumbering(); break;
+ case RichEditorShortcutId.LineSpacingSingle: SetLineSpacing(1.0); break;
+ case RichEditorShortcutId.LineSpacingOneHalf: SetLineSpacing(1.5); break;
+ case RichEditorShortcutId.LineSpacingDouble: SetLineSpacing(2.0); break;
}
}
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs
index 20bff62..77984b6 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs
@@ -668,50 +668,154 @@ private static Paragraph CellCaretTarget(TableBlock tb, int r, int c)
internal static int RowBelowIndex(TableBlock tb, int r, int c) => r + Math.Max(1, tb.SpanOf(r, c).rs);
internal static int ColumnRightIndex(TableBlock tb, int r, int c) => c + Math.Max(1, tb.SpanOf(r, c).cs);
- private void TableInsertRow(TableBlock tb, int at)
+ private bool TableInsertRow(TableBlock tb, int at)
{
- if (Document == null || at < 0) return;
+ if (Document == null || at < 0) return false;
PushUndo(null);
tb.InsertRow(at);
UpdateParents(Document);
int ar = Math.Clamp(at, 0, tb.Rows - 1);
SetCaretToCell(CellCaretTarget(tb, ar, 0));
AfterStructuralEdit(tb);
+ return true;
}
- private void TableDeleteRow(TableBlock tb, int at)
+ private bool TableDeleteRow(TableBlock tb, int at)
{
- if (Document == null || tb.Rows <= 1 || at < 0) return;
+ if (Document == null || tb.Rows <= 1 || at < 0) return false;
PushUndo(null);
tb.DeleteRow(at);
UpdateParents(Document);
int nr = Math.Clamp(at, 0, tb.Rows - 1);
SetCaretToCell(CellCaretTarget(tb, nr, 0));
AfterStructuralEdit(tb);
+ return true;
}
- private void TableInsertColumn(TableBlock tb, int at)
+ private bool TableInsertColumn(TableBlock tb, int at)
{
- if (Document == null || at < 0) return;
+ if (Document == null || at < 0) return false;
PushUndo(null);
tb.InsertColumn(at);
UpdateParents(Document);
int ac = Math.Clamp(at, 0, tb.Columns - 1);
SetCaretToCell(CellCaretTarget(tb, 0, ac));
AfterStructuralEdit(tb);
+ return true;
}
- private void TableDeleteColumn(TableBlock tb, int at)
+ private bool TableDeleteColumn(TableBlock tb, int at)
{
- if (Document == null || tb.Columns <= 1 || at < 0) return;
+ if (Document == null || tb.Columns <= 1 || at < 0) return false;
PushUndo(null);
tb.DeleteColumn(at);
UpdateParents(Document);
int nc = Math.Clamp(at, 0, tb.Columns - 1);
SetCaretToCell(CellCaretTarget(tb, 0, nc));
AfterStructuralEdit(tb);
+ return true;
+ }
+
+ // ---- public structure commands (upstream PR #48) ----------------------------------------------------
+ // The four commands above were reachable only from the context menu, so a host that builds its own
+ // toolbar - or generates a document by script - had to edit TableBlock itself, which skips the undo
+ // checkpoint, the parent wiring and the layout invalidation they do.
+ //
+ // Two shapes over one body: the Table* four name the table and the index (scripting), the caret six act
+ // where the caret is (a toolbar button). Both return false when nothing changed, and both stop on a
+ // read-only editor. AllowTables is NOT consulted: it gates CREATING tables, and the context menu's
+ // row/column items are not gated by it either.
+
+ // A caller's table is not trusted: one from another document would push an undo checkpoint for, and
+ // edit, a tree this editor does not show. Every table in the document owns at least one cell paragraph,
+ // so this reuses the document walker (AllParagraphs) + FindCell rather than a table walker of its own.
+ // (A parent-chain check would call a detached subtree "inside" — see the round-34 backport.)
+ private bool TableIsInDocument(TableBlock table)
+ {
+ foreach (var p in AllParagraphs())
+ if (FindCell(p) is { } loc && ReferenceEquals(loc.tb, table)) return true;
+ return false;
}
+ // Insertion accepts one past the end (append); deletion does not.
+ private bool EditableTable(TableBlock? table, int at, int count, bool insert)
+ => table != null && Document != null && !IsReadOnly
+ && at >= 0 && at <= (insert ? count : count - 1)
+ && TableIsInDocument(table);
+
+ /// Inserts an empty row into before row
+ /// ( == appends). A cell crossing that boundary as
+ /// part of a vertical merge grows over the new row. One undo step; the caret lands in the new row.
+ /// when the editor is read-only or has no document, the table is not
+ /// in that document, or is out of range - nothing changes in those cases.
+ public bool InsertTableRow(TableBlock table, int at)
+ => EditableTable(table, at, table?.Rows ?? 0, insert: true) && TableInsertRow(table!, at);
+
+ /// Deletes row of with the cells in it.
+ /// One undo step; the caret lands in the row that takes its place.
+ /// under the same conditions as , plus a
+ /// table of a single row, which always keeps it.
+ public bool DeleteTableRow(TableBlock table, int at)
+ => EditableTable(table, at, table?.Rows ?? 0, insert: false) && TableDeleteRow(table!, at);
+
+ /// Inserts an empty column into before column
+ /// ( == appends), at the default width.
+ /// One undo step; the caret lands in the new column.
+ /// under the same conditions as .
+ public bool InsertTableColumn(TableBlock table, int at)
+ => EditableTable(table, at, table?.Columns ?? 0, insert: true) && TableInsertColumn(table!, at);
+
+ /// Deletes column of with the cells in it.
+ /// One undo step; the caret lands in the column that takes its place.
+ /// under the same conditions as , plus a
+ /// table of a single column, which always keeps it.
+ public bool DeleteTableColumn(TableBlock table, int at)
+ => EditableTable(table, at, table?.Columns ?? 0, insert: false) && TableDeleteColumn(table!, at);
+
+ // The caret's cell as its merge anchor (what the context menu passes too), or null when the caret is
+ // not in a table - including a table held whole, which names no cell.
+ private (TableBlock tb, int r, int c)? CaretCell()
+ {
+ if (_caret.Paragraph is not { } p || FindCell(p) is not { } loc) return null;
+ var (ar, ac) = loc.tb.AnchorOf(loc.r, loc.c);
+ return (loc.tb, ar, ac);
+ }
+
+ /// Inserts a row above the caret's row. Nothing happens when the caret is not in a table.
+ /// Whether a row was inserted.
+ public bool InsertRowAbove()
+ => CaretCell() is { } at && InsertTableRow(at.tb, at.r);
+
+ /// Inserts a row below the caret's row - below the whole merged area when the caret's cell
+ /// spans several rows. Nothing happens when the caret is not in a table.
+ /// Whether a row was inserted.
+ public bool InsertRowBelow()
+ => CaretCell() is { } at && InsertTableRow(at.tb, RowBelowIndex(at.tb, at.r, at.c));
+
+ /// Deletes the caret's row. Nothing happens when the caret is not in a table, or when the
+ /// table has a single row.
+ /// Whether the row was deleted.
+ public bool DeleteRow()
+ => CaretCell() is { } at && DeleteTableRow(at.tb, at.r);
+
+ /// Inserts a column to the left of the caret's column. Nothing happens when the caret is not
+ /// in a table.
+ /// Whether a column was inserted.
+ public bool InsertColumnLeft()
+ => CaretCell() is { } at && InsertTableColumn(at.tb, at.c);
+
+ /// Inserts a column to the right of the caret's column - right of the whole merged area when
+ /// the caret's cell spans several columns. Nothing happens when the caret is not in a table.
+ /// Whether a column was inserted.
+ public bool InsertColumnRight()
+ => CaretCell() is { } at && InsertTableColumn(at.tb, ColumnRightIndex(at.tb, at.r, at.c));
+
+ /// Deletes the caret's column. Nothing happens when the caret is not in a table, or when the
+ /// table has a single column.
+ /// Whether the column was deleted.
+ public bool DeleteColumn()
+ => CaretCell() is { } at && DeleteTableColumn(at.tb, at.c);
+
private void TableMergeSelected(TableBlock tb)
{
if (Document == null || SelectedCellRange(tb) is not { } g || !IsCleanRect(tb, g.r0, g.c0, g.r1, g.c1)) return;
diff --git a/src/WinUIRichEditor/Controls/RichEditorShortcuts.cs b/src/WinUIRichEditor/Controls/RichEditorShortcuts.cs
index 71790ea..8ccd69a 100644
--- a/src/WinUIRichEditor/Controls/RichEditorShortcuts.cs
+++ b/src/WinUIRichEditor/Controls/RichEditorShortcuts.cs
@@ -3,88 +3,165 @@
namespace WinUIRichEditor.Controls;
-/// Identifies a command that has a keyboard shortcut. Used as the key that ties the shortcut
-/// table, the editor's key handler, the context-menu hints, and the toolbar tooltips together.
-internal enum ShortcutId
+/// Identifies a command that has a keyboard shortcut. The key that ties the shortcut table, the
+/// editor's key handler, the context-menu hints and the toolbar tooltips together.
+/// Public so a host building its own toolbar or menu can label a command with the gesture the editor
+/// actually acts on (upstream PR #49). New commands are appended, so the numeric values keep their meaning
+/// (as does). The order is this package's own, not upstream's.
+public enum RichEditorShortcutId
{
- Cut, Copy, Paste, PastePlain, SelectAll, Undo, Redo,
- Bold, Italic, Underline, Strikethrough, FontLarger, FontSmaller,
- IndentIncrease, IndentDecrease,
- AlignLeft, AlignCenter, AlignRight, AlignJustify,
- Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, BodyText,
- BulletList, NumberedList, LineSpacingSingle, LineSpacingOneHalf, LineSpacingDouble,
- Find, FindReplace, InsertLink,
+ /// Cut the selection to the clipboard.
+ Cut,
+ /// Copy the selection to the clipboard.
+ Copy,
+ /// Paste, keeping whatever formatting the clipboard carries.
+ Paste,
+ /// Paste the clipboard's plain text only.
+ PastePlain,
+ /// Select the cell's contents, then the table, then the document (staged).
+ SelectAll,
+ /// Undo the last edit.
+ Undo,
+ /// Redo the last undone edit.
+ Redo,
+ /// Bold toggle.
+ Bold,
+ /// Italic toggle.
+ Italic,
+ /// Underline toggle.
+ Underline,
+ /// Strikethrough toggle.
+ Strikethrough,
+ /// Next size up the standard ladder.
+ FontLarger,
+ /// Next size down the standard ladder.
+ FontSmaller,
+ /// Indent the paragraph one step.
+ IndentIncrease,
+ /// Outdent the paragraph one step.
+ IndentDecrease,
+ /// Align the paragraph left.
+ AlignLeft,
+ /// Centre the paragraph.
+ AlignCenter,
+ /// Align the paragraph right.
+ AlignRight,
+ /// Justify the paragraph.
+ AlignJustify,
+ /// Make the paragraph a level 1 heading.
+ Heading1,
+ /// Make the paragraph a level 2 heading.
+ Heading2,
+ /// Make the paragraph a level 3 heading.
+ Heading3,
+ /// Make the paragraph a level 4 heading.
+ Heading4,
+ /// Make the paragraph a level 5 heading.
+ Heading5,
+ /// Make the paragraph a level 6 heading.
+ Heading6,
+ /// Make the paragraph body text (heading level 0).
+ BodyText,
+ /// Bulleted list toggle.
+ BulletList,
+ /// Numbered list toggle.
+ NumberedList,
+ /// Line spacing 100% of the font size.
+ LineSpacingSingle,
+ /// Line spacing 150% of the font size.
+ LineSpacingOneHalf,
+ /// Line spacing 200% of the font size.
+ LineSpacingDouble,
+ /// Find.
+ Find,
+ /// Find and replace.
+ FindReplace,
+ /// Open the link dialog for the selection or the caret's word.
+ InsertLink,
+ /// Select the caret's cell as a block (HWP's cell block key).
SelectCell,
}
-internal readonly record struct ShortcutSpec(ShortcutId Id, bool Ctrl, bool Shift, bool Alt, VirtualKey Key, string Display);
+/// One shortcut in the table: the command, the modifiers it needs (each must match exactly), the
+/// key, and the hint text to show for it (e.g. "Ctrl+B").
+public readonly record struct RichEditorShortcut(RichEditorShortcutId Id, bool Ctrl, bool Shift, bool Alt, VirtualKey Key, string Display);
/// The single source of truth for command keyboard shortcuts (Word-standard scheme). The editor's
-/// OnKeyDown matches events against ; the context menu and toolbar read
-/// for their hint text — so behavior and the shown shortcut never drift.
-internal static class RichEditorShortcuts
+/// OnKeyDown matches events against this table; the context menu and toolbar read
+/// for their hint text — so behavior and the shown shortcut never drift.
+/// A host that builds its own toolbar or menu reads it the same way, instead of writing "Ctrl+B"
+/// again somewhere the editor cannot see.
+public static class RichEditorShortcuts
{
private const VirtualKey OemComma = (VirtualKey)0xBC; // ',' (Shift → '<')
private const VirtualKey OemPeriod = (VirtualKey)0xBE; // '.' (Shift → '>')
- public static readonly ShortcutSpec[] All =
+ // Public callers get the list through All, which hands out no reference to this array: a host that
+ // could reorder or blank an entry would be editing what the editor's own key handler matches against.
+ private static readonly RichEditorShortcut[] Table =
{
- new(ShortcutId.Cut, true, false, false, VirtualKey.X, "Ctrl+X"),
- new(ShortcutId.Copy, true, false, false, VirtualKey.C, "Ctrl+C"),
- new(ShortcutId.Paste, true, false, false, VirtualKey.V, "Ctrl+V"),
- new(ShortcutId.PastePlain, true, true, false, VirtualKey.V, "Ctrl+Shift+V"),
- new(ShortcutId.SelectAll, true, false, false, VirtualKey.A, "Ctrl+A"),
- new(ShortcutId.Undo, true, false, false, VirtualKey.Z, "Ctrl+Z"),
- new(ShortcutId.Redo, true, false, false, VirtualKey.Y, "Ctrl+Y"),
- new(ShortcutId.Redo, true, true, false, VirtualKey.Z, "Ctrl+Shift+Z"), // alias (display keeps Ctrl+Y)
- new(ShortcutId.Bold, true, false, false, VirtualKey.B, "Ctrl+B"),
- new(ShortcutId.Italic, true, false, false, VirtualKey.I, "Ctrl+I"),
- new(ShortcutId.Underline, true, false, false, VirtualKey.U, "Ctrl+U"),
- new(ShortcutId.Strikethrough, true, true, false, VirtualKey.X, "Ctrl+Shift+X"),
- new(ShortcutId.FontLarger, true, true, false, OemPeriod, "Ctrl+Shift+."),
- new(ShortcutId.FontSmaller, true, true, false, OemComma, "Ctrl+Shift+,"),
- new(ShortcutId.IndentIncrease,true, false, false, VirtualKey.M, "Ctrl+M"),
- new(ShortcutId.IndentDecrease,true, true, false, VirtualKey.M, "Ctrl+Shift+M"),
- new(ShortcutId.AlignLeft, true, false, false, VirtualKey.L, "Ctrl+L"),
- new(ShortcutId.AlignCenter, true, false, false, VirtualKey.E, "Ctrl+E"),
- new(ShortcutId.AlignRight, true, false, false, VirtualKey.R, "Ctrl+R"),
- new(ShortcutId.AlignJustify, true, false, false, VirtualKey.J, "Ctrl+J"),
- new(ShortcutId.Heading1, true, false, true, VirtualKey.Number1, "Ctrl+Alt+1"),
- new(ShortcutId.Heading2, true, false, true, VirtualKey.Number2, "Ctrl+Alt+2"),
- new(ShortcutId.Heading3, true, false, true, VirtualKey.Number3, "Ctrl+Alt+3"),
- new(ShortcutId.Heading4, true, false, true, VirtualKey.Number4, "Ctrl+Alt+4"),
- new(ShortcutId.Heading5, true, false, true, VirtualKey.Number5, "Ctrl+Alt+5"),
- new(ShortcutId.Heading6, true, false, true, VirtualKey.Number6, "Ctrl+Alt+6"),
- new(ShortcutId.BodyText, true, true, false, VirtualKey.N, "Ctrl+Shift+N"),
- new(ShortcutId.BulletList, true, true, false, VirtualKey.L, "Ctrl+Shift+L"),
- new(ShortcutId.NumberedList, true, true, false, VirtualKey.Number7, "Ctrl+Shift+7"), // Docs convention; Word has no standard binding
- new(ShortcutId.LineSpacingSingle, true, false, false, VirtualKey.Number1, "Ctrl+1"),
- new(ShortcutId.LineSpacingOneHalf, true, false, false, VirtualKey.Number5, "Ctrl+5"),
- new(ShortcutId.LineSpacingDouble, true, false, false, VirtualKey.Number2, "Ctrl+2"),
- new(ShortcutId.Find, true, false, false, VirtualKey.F, "Ctrl+F"),
- new(ShortcutId.FindReplace, true, false, false, VirtualKey.H, "Ctrl+H"),
- new(ShortcutId.InsertLink, true, false, false, VirtualKey.K, "Ctrl+K"),
+ new(RichEditorShortcutId.Cut, true, false, false, VirtualKey.X, "Ctrl+X"),
+ new(RichEditorShortcutId.Copy, true, false, false, VirtualKey.C, "Ctrl+C"),
+ new(RichEditorShortcutId.Paste, true, false, false, VirtualKey.V, "Ctrl+V"),
+ new(RichEditorShortcutId.PastePlain, true, true, false, VirtualKey.V, "Ctrl+Shift+V"),
+ new(RichEditorShortcutId.SelectAll, true, false, false, VirtualKey.A, "Ctrl+A"),
+ new(RichEditorShortcutId.Undo, true, false, false, VirtualKey.Z, "Ctrl+Z"),
+ new(RichEditorShortcutId.Redo, true, false, false, VirtualKey.Y, "Ctrl+Y"),
+ new(RichEditorShortcutId.Redo, true, true, false, VirtualKey.Z, "Ctrl+Shift+Z"), // alias (display keeps Ctrl+Y)
+ new(RichEditorShortcutId.Bold, true, false, false, VirtualKey.B, "Ctrl+B"),
+ new(RichEditorShortcutId.Italic, true, false, false, VirtualKey.I, "Ctrl+I"),
+ new(RichEditorShortcutId.Underline, true, false, false, VirtualKey.U, "Ctrl+U"),
+ new(RichEditorShortcutId.Strikethrough, true, true, false, VirtualKey.X, "Ctrl+Shift+X"),
+ new(RichEditorShortcutId.FontLarger, true, true, false, OemPeriod, "Ctrl+Shift+."),
+ new(RichEditorShortcutId.FontSmaller, true, true, false, OemComma, "Ctrl+Shift+,"),
+ new(RichEditorShortcutId.IndentIncrease,true, false, false, VirtualKey.M, "Ctrl+M"),
+ new(RichEditorShortcutId.IndentDecrease,true, true, false, VirtualKey.M, "Ctrl+Shift+M"),
+ new(RichEditorShortcutId.AlignLeft, true, false, false, VirtualKey.L, "Ctrl+L"),
+ new(RichEditorShortcutId.AlignCenter, true, false, false, VirtualKey.E, "Ctrl+E"),
+ new(RichEditorShortcutId.AlignRight, true, false, false, VirtualKey.R, "Ctrl+R"),
+ new(RichEditorShortcutId.AlignJustify, true, false, false, VirtualKey.J, "Ctrl+J"),
+ new(RichEditorShortcutId.Heading1, true, false, true, VirtualKey.Number1, "Ctrl+Alt+1"),
+ new(RichEditorShortcutId.Heading2, true, false, true, VirtualKey.Number2, "Ctrl+Alt+2"),
+ new(RichEditorShortcutId.Heading3, true, false, true, VirtualKey.Number3, "Ctrl+Alt+3"),
+ new(RichEditorShortcutId.Heading4, true, false, true, VirtualKey.Number4, "Ctrl+Alt+4"),
+ new(RichEditorShortcutId.Heading5, true, false, true, VirtualKey.Number5, "Ctrl+Alt+5"),
+ new(RichEditorShortcutId.Heading6, true, false, true, VirtualKey.Number6, "Ctrl+Alt+6"),
+ new(RichEditorShortcutId.BodyText, true, true, false, VirtualKey.N, "Ctrl+Shift+N"),
+ new(RichEditorShortcutId.BulletList, true, true, false, VirtualKey.L, "Ctrl+Shift+L"),
+ new(RichEditorShortcutId.NumberedList, true, true, false, VirtualKey.Number7, "Ctrl+Shift+7"), // Docs convention; Word has no standard binding
+ new(RichEditorShortcutId.LineSpacingSingle, true, false, false, VirtualKey.Number1, "Ctrl+1"),
+ new(RichEditorShortcutId.LineSpacingOneHalf, true, false, false, VirtualKey.Number5, "Ctrl+5"),
+ new(RichEditorShortcutId.LineSpacingDouble, true, false, false, VirtualKey.Number2, "Ctrl+2"),
+ new(RichEditorShortcutId.Find, true, false, false, VirtualKey.F, "Ctrl+F"),
+ new(RichEditorShortcutId.FindReplace, true, false, false, VirtualKey.H, "Ctrl+H"),
+ new(RichEditorShortcutId.InsertLink, true, false, false, VirtualKey.K, "Ctrl+K"),
// Not Ctrl-modified, so TryMatch (reached only with Ctrl) never runs it: OnEditorKeyDown routes F5
// through TryCellBlockKey. Listed for the menu hint (HWP's cell block key).
- new(ShortcutId.SelectCell, false, false, false, VirtualKey.F5, "F5"),
+ new(RichEditorShortcutId.SelectCell, false, false, false, VirtualKey.F5, "F5"),
};
- private static readonly Dictionary DisplayMap = BuildDisplayMap();
+ /// Every shortcut, in the order the key handler matches them. A command can appear more than
+ /// once (an alias, such as Ctrl+Shift+Z for redo); the first entry is its primary one.
+ public static IReadOnlyList All { get; } = System.Array.AsReadOnly(Table);
- private static Dictionary BuildDisplayMap()
+ private static readonly Dictionary DisplayMap = BuildDisplayMap();
+
+ private static Dictionary BuildDisplayMap()
{
- var d = new Dictionary();
- foreach (var s in All) d.TryAdd(s.Id, s.Display); // keep the first (primary) display per id
+ var d = new Dictionary();
+ foreach (var s in Table) d.TryAdd(s.Id, s.Display); // keep the first (primary) display per id
return d;
}
/// The shortcut hint text for a command (e.g. "Ctrl+B"), or "" if none.
- public static string Display(ShortcutId id) => DisplayMap.TryGetValue(id, out var s) ? s : "";
+ public static string Display(RichEditorShortcutId id) => DisplayMap.TryGetValue(id, out var s) ? s : "";
/// Matches a key event to a command. Modifiers must match exactly.
- public static bool TryMatch(bool ctrl, bool shift, bool alt, VirtualKey key, out ShortcutId id)
+ // Internal on purpose: a host that wants this can match against All itself, and the editor keeps the
+ // freedom to change how it routes keys (F5, for one, is in the table only as a menu hint).
+ internal static bool TryMatch(bool ctrl, bool shift, bool alt, VirtualKey key, out RichEditorShortcutId id)
{
- foreach (var s in All)
+ foreach (var s in Table)
if (s.Ctrl == ctrl && s.Shift == shift && s.Alt == alt && s.Key == key) { id = s.Id; return true; }
id = default;
return false;
diff --git a/src/WinUIRichEditor/Controls/RichEditorToolbar.cs b/src/WinUIRichEditor/Controls/RichEditorToolbar.cs
index 9d4683c..83d4d30 100644
--- a/src/WinUIRichEditor/Controls/RichEditorToolbar.cs
+++ b/src/WinUIRichEditor/Controls/RichEditorToolbar.cs
@@ -222,7 +222,7 @@ public static string[] Palette
private static string Loc(string key) => RichEditorLocalization.GetString(key);
// Tooltip with the command's shortcut appended, e.g. "굵게 (Ctrl+B)". Single-sourced from the table.
- private static string TipSc(string key, ShortcutId id) => Loc(key) + " (" + RichEditorShortcuts.Display(id) + ")";
+ private static string TipSc(string key, RichEditorShortcutId id) => Loc(key) + " (" + RichEditorShortcuts.Display(id) + ")";
public RichEditorToolbar()
{
@@ -362,7 +362,7 @@ private UIElement Build()
{
// Read-only = view toolbar: find + page/zoom + Export/Print (no editing controls, Import
// hidden). Find works read-only, so a viewer keeps it.
- _findBtn = IconButton("🔎" + Mono, TipSc("Find", ShortcutId.Find),
+ _findBtn = IconButton("🔎" + Mono, TipSc("Find", RichEditorShortcutId.Find),
() => Target?.RaiseFindRequested(false), RichEditorIcon.Find);
Add(_findBtn); AddSep();
bool page = ShowPageControls, file = ShowFileActions;
@@ -374,15 +374,15 @@ private UIElement Build()
// Group order mirrors the AvaloniaRichEditor original toolbar: history → character
// toggles → colours → font face/size → paragraph style/align → lists·indent·spacing →
// inserts (table/image/divider) → page/zoom → file actions.
- _undo = IconButton("↶", TipSc("Undo", ShortcutId.Undo), () => Target?.Undo(), RichEditorIcon.Undo);
- _redo = IconButton("↷", TipSc("Redo", ShortcutId.Redo), () => Target?.Redo(), RichEditorIcon.Redo);
+ _undo = IconButton("↶", TipSc("Undo", RichEditorShortcutId.Undo), () => Target?.Undo(), RichEditorIcon.Undo);
+ _redo = IconButton("↷", TipSc("Redo", RichEditorShortcutId.Redo), () => Target?.Redo(), RichEditorIcon.Redo);
Add(_undo); Add(_redo); AddSep();
- _bold = ToggleBtn("B", TipSc("Bold", ShortcutId.Bold), () => Target?.ToggleBold(), bold: true, icon: RichEditorIcon.Bold);
- _italic = ToggleBtn("I", TipSc("Italic", ShortcutId.Italic), () => Target?.ToggleItalic(), italic: true, icon: RichEditorIcon.Italic);
- _underline = ToggleBtn("U", TipSc("Underline", ShortcutId.Underline), () => Target?.ToggleUnderline(), icon: RichEditorIcon.Underline,
+ _bold = ToggleBtn("B", TipSc("Bold", RichEditorShortcutId.Bold), () => Target?.ToggleBold(), bold: true, icon: RichEditorIcon.Bold);
+ _italic = ToggleBtn("I", TipSc("Italic", RichEditorShortcutId.Italic), () => Target?.ToggleItalic(), italic: true, icon: RichEditorIcon.Italic);
+ _underline = ToggleBtn("U", TipSc("Underline", RichEditorShortcutId.Underline), () => Target?.ToggleUnderline(), icon: RichEditorIcon.Underline,
decorations: Windows.UI.Text.TextDecorations.Underline);
- _strike = ToggleBtn("S", TipSc("Strikethrough", ShortcutId.Strikethrough), () => Target?.ToggleStrikethrough(), icon: RichEditorIcon.Strikethrough,
+ _strike = ToggleBtn("S", TipSc("Strikethrough", RichEditorShortcutId.Strikethrough), () => Target?.ToggleStrikethrough(), icon: RichEditorIcon.Strikethrough,
decorations: Windows.UI.Text.TextDecorations.Strikethrough);
Add(_bold); Add(_italic); Add(_underline); Add(_strike);
@@ -459,8 +459,8 @@ private UIElement Build()
// needs ShowFormattingMenu, and there is no shortcut). Upstream decision, 2026-09-23.
_quote = IconButton("❝", Loc("Quote"), () => Target?.ToggleQuote(), RichEditorIcon.Quote);
Add(_quote);
- Add(IconButton("⇥", TipSc("IndentIncrease", ShortcutId.IndentIncrease), () => Target?.Indent(20), RichEditorIcon.IndentIncrease));
- Add(IconButton("⇤", TipSc("IndentDecrease", ShortcutId.IndentDecrease), () => Target?.Indent(-20), RichEditorIcon.IndentDecrease));
+ Add(IconButton("⇥", TipSc("IndentIncrease", RichEditorShortcutId.IndentIncrease), () => Target?.Indent(20), RichEditorIcon.IndentIncrease));
+ Add(IconButton("⇤", TipSc("IndentDecrease", RichEditorShortcutId.IndentDecrease), () => Target?.Indent(-20), RichEditorIcon.IndentDecrease));
Add(BuildLineSpacingControl());
AddSep();
@@ -474,7 +474,7 @@ private UIElement Build()
// Find: opens whatever find UI the host wired to RichEditor.FindRequested (the built-in
// bar in RichEditorView), the same path Ctrl+F takes. Hidden when find is disabled.
AddSep();
- _findBtn = IconButton("🔎" + Mono, TipSc("Find", ShortcutId.Find),
+ _findBtn = IconButton("🔎" + Mono, TipSc("Find", RichEditorShortcutId.Find),
() => Target?.RaiseFindRequested(false), RichEditorIcon.Find);
Add(_findBtn);
}
diff --git a/src/WinUIRichEditor/PublicAPI.Unshipped.txt b/src/WinUIRichEditor/PublicAPI.Unshipped.txt
index f986230..8b6a214 100644
--- a/src/WinUIRichEditor/PublicAPI.Unshipped.txt
+++ b/src/WinUIRichEditor/PublicAPI.Unshipped.txt
@@ -1,4 +1,4 @@
-WinUIRichEditor.Controls.RichEditorIcon.Quote = 49 -> WinUIRichEditor.Controls.RichEditorIcon
+WinUIRichEditor.Controls.RichEditorIcon.Quote = 49 -> WinUIRichEditor.Controls.RichEditorIcon
WinUIRichEditor.Controls.RichEditor.PageMargin.get -> WinUIRichEditor.Documents.PageMargins
WinUIRichEditor.Controls.RichEditor.PageMargin.set -> void
WinUIRichEditor.Documents.PageMargins
@@ -23,3 +23,67 @@ static WinUIRichEditor.Documents.PageSetup.PaperMillimetres(WinUIRichEditor.Cont
static readonly WinUIRichEditor.Controls.RichEditor.PageMarginProperty -> Microsoft.UI.Xaml.DependencyProperty!
static WinUIRichEditor.Documents.Block.AutoTopMargin.get -> double
WinUIRichEditor.Controls.RichEditorIcon.PageMargin = 50 -> WinUIRichEditor.Controls.RichEditorIcon
+WinUIRichEditor.Controls.RichEditor.InsertTableRow(WinUIRichEditor.Documents.TableBlock! table, int at) -> bool
+WinUIRichEditor.Controls.RichEditor.DeleteTableRow(WinUIRichEditor.Documents.TableBlock! table, int at) -> bool
+WinUIRichEditor.Controls.RichEditor.InsertTableColumn(WinUIRichEditor.Documents.TableBlock! table, int at) -> bool
+WinUIRichEditor.Controls.RichEditor.DeleteTableColumn(WinUIRichEditor.Documents.TableBlock! table, int at) -> bool
+WinUIRichEditor.Controls.RichEditor.InsertRowAbove() -> bool
+WinUIRichEditor.Controls.RichEditor.InsertRowBelow() -> bool
+WinUIRichEditor.Controls.RichEditor.DeleteRow() -> bool
+WinUIRichEditor.Controls.RichEditor.InsertColumnLeft() -> bool
+WinUIRichEditor.Controls.RichEditor.InsertColumnRight() -> bool
+WinUIRichEditor.Controls.RichEditor.DeleteColumn() -> bool
+WinUIRichEditor.Controls.RichEditorShortcut
+WinUIRichEditor.Controls.RichEditorShortcut.Alt.get -> bool
+WinUIRichEditor.Controls.RichEditorShortcut.Alt.init -> void
+WinUIRichEditor.Controls.RichEditorShortcut.Ctrl.get -> bool
+WinUIRichEditor.Controls.RichEditorShortcut.Ctrl.init -> void
+WinUIRichEditor.Controls.RichEditorShortcut.Display.get -> string!
+WinUIRichEditor.Controls.RichEditorShortcut.Display.init -> void
+WinUIRichEditor.Controls.RichEditorShortcut.Id.get -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcut.Id.init -> void
+WinUIRichEditor.Controls.RichEditorShortcut.Key.get -> Windows.System.VirtualKey
+WinUIRichEditor.Controls.RichEditorShortcut.Key.init -> void
+WinUIRichEditor.Controls.RichEditorShortcut.RichEditorShortcut() -> void
+WinUIRichEditor.Controls.RichEditorShortcut.RichEditorShortcut(WinUIRichEditor.Controls.RichEditorShortcutId Id, bool Ctrl, bool Shift, bool Alt, Windows.System.VirtualKey Key, string! Display) -> void
+WinUIRichEditor.Controls.RichEditorShortcut.Shift.get -> bool
+WinUIRichEditor.Controls.RichEditorShortcut.Shift.init -> void
+WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Cut = 0 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Copy = 1 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Paste = 2 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.PastePlain = 3 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.SelectAll = 4 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Undo = 5 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Redo = 6 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Bold = 7 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Italic = 8 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Underline = 9 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Strikethrough = 10 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.FontLarger = 11 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.FontSmaller = 12 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.IndentIncrease = 13 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.IndentDecrease = 14 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.AlignLeft = 15 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.AlignCenter = 16 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.AlignRight = 17 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.AlignJustify = 18 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Heading1 = 19 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Heading2 = 20 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Heading3 = 21 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Heading4 = 22 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Heading5 = 23 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Heading6 = 24 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.BodyText = 25 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.BulletList = 26 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.NumberedList = 27 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.LineSpacingSingle = 28 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.LineSpacingOneHalf = 29 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.LineSpacingDouble = 30 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.Find = 31 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.FindReplace = 32 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.InsertLink = 33 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcutId.SelectCell = 34 -> WinUIRichEditor.Controls.RichEditorShortcutId
+WinUIRichEditor.Controls.RichEditorShortcuts
+static WinUIRichEditor.Controls.RichEditorShortcuts.All.get -> System.Collections.Generic.IReadOnlyList!
+static WinUIRichEditor.Controls.RichEditorShortcuts.Display(WinUIRichEditor.Controls.RichEditorShortcutId id) -> string!
diff --git a/tests/WinUIRichEditor.Tests/ControlFeatureFlagTests.cs b/tests/WinUIRichEditor.Tests/ControlFeatureFlagTests.cs
index 8c4cd01..cb21ca7 100644
--- a/tests/WinUIRichEditor.Tests/ControlFeatureFlagTests.cs
+++ b/tests/WinUIRichEditor.Tests/ControlFeatureFlagTests.cs
@@ -492,7 +492,7 @@ public void TheFindShortcuts_AskForTheFindUi_OnlyWhenAllowed(bool allowed) => Ui
ed.AllowFindReplace = allowed;
int asked = 0;
ed.FindRequested += (_, _) => asked++;
- var shortcutType = T.Assembly.GetType("WinUIRichEditor.Controls.ShortcutId")!;
+ var shortcutType = T.Assembly.GetType("WinUIRichEditor.Controls.RichEditorShortcutId")!;
Call(ed, "RunShortcut", Enum.Parse(shortcutType, "Find"));
Call(ed, "RunShortcut", Enum.Parse(shortcutType, "FindReplace"));
diff --git a/tests/WinUIRichEditor.Tests/PublicShortcutTableTests.cs b/tests/WinUIRichEditor.Tests/PublicShortcutTableTests.cs
new file mode 100644
index 0000000..5ab63eb
--- /dev/null
+++ b/tests/WinUIRichEditor.Tests/PublicShortcutTableTests.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using Microsoft.UI.Xaml;
+using Windows.UI.Text;
+using WinUIRichEditor.Controls;
+using WinUIRichEditor.Documents;
+using Xunit;
+
+namespace WinUIRichEditor.Tests;
+
+/// The shortcut table is public (upstream PR #49): a host building its own toolbar or menu could not
+/// read the gestures the editor acts on, so it had to write "Ctrl+B" again somewhere the editor cannot see —
+/// and the two drift the day a binding changes.
+/// What does not already hold: the table hands out no way to edit
+/// itself, every command has a hint, and pressing what the table advertises does what it says. (Upstream
+/// also publishes Gesture(id) for Avalonia menus; a WinUI menu takes the hint as a string, so the port
+/// has no counterpart.)
+[Collection(UiTests.Collection)]
+public class PublicShortcutTableTests
+{
+ private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;
+
+ private static RichEditorShortcut Primary(RichEditorShortcutId id)
+ => RichEditorShortcuts.All.First(s => s.Id == id);
+
+ [Fact]
+ public void TheTableHandsOutNoWayToEditIt()
+ {
+ var all = RichEditorShortcuts.All;
+
+ // A host that could write here would be editing what the editor's own key handler matches against.
+ Assert.IsNotType(all);
+ if (all is IList writable)
+ {
+ Assert.True(writable.IsReadOnly);
+ Assert.Throws(() => writable[0] = default);
+ }
+ }
+
+ [Fact]
+ public void EveryCommandHasAHint()
+ {
+ foreach (RichEditorShortcutId id in Enum.GetValues())
+ Assert.False(string.IsNullOrEmpty(RichEditorShortcuts.Display(id)), $"{id} has no hint");
+ }
+
+ // The tie to behaviour: the chord the table advertises goes the way OnEditorKeyDown sends it (TryMatch,
+ // then RunShortcut — a KeyRoutedEventArgs has no public constructor) and the command runs. Without this
+ // the rest only proves the table is self-consistent — it could be self-consistent and wrong.
+ [Theory]
+ [InlineData(RichEditorShortcutId.Bold)]
+ [InlineData(RichEditorShortcutId.Italic)]
+ [InlineData(RichEditorShortcutId.AlignCenter)]
+ [InlineData(RichEditorShortcutId.Heading1)]
+ public void PressingWhatTheTableAdvertises_RunsTheCommand(RichEditorShortcutId id) => UiThread.Run(() =>
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "text" } } });
+ var ed = new RichEditor { Document = doc };
+ Press(ed, Primary(RichEditorShortcutId.SelectAll)); // select it all
+
+ Press(ed, Primary(id));
+
+ var p = ed.Document!.Blocks.OfType().First();
+ switch (id)
+ {
+ case RichEditorShortcutId.Bold:
+ Assert.Equal(700, p.Inlines.OfType().First().FontWeight.Weight);
+ break;
+ case RichEditorShortcutId.Italic:
+ Assert.Equal(FontStyle.Italic, p.Inlines.OfType().First().FontStyle);
+ break;
+ case RichEditorShortcutId.AlignCenter:
+ Assert.Equal(TextAlignment.Center, p.TextAlignment);
+ break;
+ case RichEditorShortcutId.Heading1:
+ Assert.Equal(1, p.HeadingLevel);
+ break;
+ }
+ });
+
+ private static void Press(RichEditor ed, RichEditorShortcut s)
+ {
+ Assert.True(RichEditorShortcuts.TryMatch(s.Ctrl, s.Shift, s.Alt, s.Key, out var id));
+ try { typeof(RichEditor).GetMethod("RunShortcut", NP)!.Invoke(ed, new object[] { id }); }
+ catch (TargetInvocationException tie) when (tie.InnerException != null)
+ {
+ ExceptionDispatchInfo.Capture(tie.InnerException).Throw();
+ }
+ }
+}
diff --git a/tests/WinUIRichEditor.Tests/ShortcutTableTests.cs b/tests/WinUIRichEditor.Tests/ShortcutTableTests.cs
index 360180d..15115c0 100644
--- a/tests/WinUIRichEditor.Tests/ShortcutTableTests.cs
+++ b/tests/WinUIRichEditor.Tests/ShortcutTableTests.cs
@@ -53,8 +53,8 @@ public void EveryDisplayString_IsItsOwnChord()
public void AnAliasMatches_ButThePrimaryIsWhatIsShown()
{
Assert.True(RichEditorShortcuts.TryMatch(true, true, false, VirtualKey.Z, out var id));
- Assert.Equal(ShortcutId.Redo, id);
- Assert.Equal("Ctrl+Y", RichEditorShortcuts.Display(ShortcutId.Redo));
+ Assert.Equal(RichEditorShortcutId.Redo, id);
+ Assert.Equal("Ctrl+Y", RichEditorShortcuts.Display(RichEditorShortcutId.Redo));
}
// Modifiers must match exactly: Ctrl+Shift+X is strikethrough, not cut; Ctrl+Alt+1 a heading, not single spacing.
diff --git a/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs b/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs
new file mode 100644
index 0000000..dfb5e7c
--- /dev/null
+++ b/tests/WinUIRichEditor.Tests/TableStructureApiTests.cs
@@ -0,0 +1,288 @@
+using System.Linq;
+using System.Reflection;
+using WinUIRichEditor.Controls;
+using WinUIRichEditor.Documents;
+using Xunit;
+
+namespace WinUIRichEditor.Tests;
+
+/// The public row/column commands (upstream PR #48, 2026-09-20). They existed only behind the
+/// context menu, so a host building its own toolbar — or generating a document by script — had to edit
+/// itself and skip the undo checkpoint, the parent wiring and the layout
+/// invalidation the commands do.
+/// Two shapes over the same body: Table* names the table and the index, the caret six act where
+/// the caret is. These tests hold what a host cannot see: the guards on a public entry point (an index from
+/// anywhere, a table from another document, a read-only editor), that one call is one undo step, and that
+/// "below/right" means past a merged area — the rule the menu uses (RowBelowIndex).
+[Collection(UiTests.Collection)]
+public class TableStructureApiTests
+{
+ private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;
+
+ private static void PlaceCaret(RichEditor ed, Paragraph p)
+ {
+ foreach (var f in new[] { "_caret", "_selStart", "_selEnd" })
+ typeof(RichEditor).GetField(f, NP)!.SetValue(ed, new TextPointer(p, 0));
+ }
+
+ // Built first, then assigned: the Document setter wires Parent (see ControlUndoTests).
+ private static RichEditor Editor(params Block[] blocks)
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "plain" } } });
+ doc.Blocks.AddRange(blocks);
+ doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "end" } } });
+ // A Document swap sets IsModified here (upstream's load clears it) — clear it so the "nothing
+ // changed" assertions measure the command, not the setup.
+ var ed = new RichEditor { Document = doc };
+ ed.MarkSaved();
+ return ed;
+ }
+
+ private static TableBlock Table(RichEditor ed) => ed.Document!.Blocks.OfType().Single();
+
+ [Fact]
+ public void InsertingARow_AddsIt_AndIsOneUndoStep() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+
+ Assert.True(ed.InsertTableRow(Table(ed), 1));
+ Assert.Equal(3, Table(ed).Rows);
+
+ ed.Undo();
+ Assert.Equal(2, Table(ed).Rows);
+ });
+
+ [Fact]
+ public void InsertingAtTheRowCount_Appends() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+
+ Assert.True(ed.InsertTableRow(tb, tb.Rows));
+
+ Assert.Equal(3, tb.Rows);
+ });
+
+ [Fact]
+ public void DeletingARow_RemovesIt_AndIsOneUndoStep() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+
+ Assert.True(ed.DeleteTableRow(Table(ed), 0));
+ Assert.Equal(1, Table(ed).Rows);
+
+ ed.Undo();
+ Assert.Equal(2, Table(ed).Rows);
+ });
+
+ [Fact]
+ public void ColumnsBehaveLikeRows() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+
+ Assert.True(ed.InsertTableColumn(tb, 0));
+ Assert.Equal(3, tb.Columns);
+ Assert.True(ed.DeleteTableColumn(tb, 0));
+ Assert.Equal(2, tb.Columns);
+ });
+
+ // A table always keeps its last row and column — the model refuses, and the public entry has to report
+ // that rather than push an undo step for an edit that did not happen.
+ [Fact]
+ public void TheLastRowAndColumnAreKept() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(1, 1));
+ var tb = Table(ed);
+
+ Assert.False(ed.DeleteTableRow(tb, 0));
+ Assert.False(ed.DeleteTableColumn(tb, 0));
+
+ Assert.Equal(1, tb.Rows);
+ Assert.Equal(1, tb.Columns);
+ Assert.False(ed.IsModified);
+ });
+
+ // An index from a host is not trusted. Deletion takes 0..count-1, insertion one more (append).
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(3)]
+ [InlineData(int.MaxValue)]
+ public void AnOutOfRangeIndex_ChangesNothing(int at) => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+
+ Assert.False(ed.InsertTableRow(tb, at)); // 2 rows: 0..2 is in range, 3 is not
+ Assert.False(ed.DeleteTableRow(tb, at));
+ Assert.False(ed.InsertTableColumn(tb, at));
+ Assert.False(ed.DeleteTableColumn(tb, at));
+
+ Assert.Equal(2, tb.Rows);
+ Assert.Equal(2, tb.Columns);
+ Assert.False(ed.IsModified); // no undo checkpoint either
+ });
+
+ // The worst input: a live table that belongs to a DIFFERENT document. Editing it would push an undo
+ // checkpoint for, and mutate, a tree this editor does not show.
+ [Fact]
+ public void ATableFromAnotherDocument_IsRefused() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var other = Editor(new TableBlock(2, 2));
+ var foreign = Table(other);
+
+ Assert.False(ed.InsertTableRow(foreign, 0));
+ Assert.False(ed.DeleteTableRow(foreign, 0));
+ Assert.False(ed.InsertTableColumn(foreign, 0));
+ Assert.False(ed.DeleteTableColumn(foreign, 0));
+
+ Assert.Equal(2, foreign.Rows);
+ Assert.Equal(2, foreign.Columns);
+ Assert.False(ed.IsModified);
+ Assert.False(other.IsModified);
+ });
+
+ // A table the editor's document USED to hold still has its Parent wired to that document — a
+ // parent-chain check would call it "inside" (the round-34 lesson). The walker does not.
+ [Fact]
+ public void ATableRemovedFromTheDocument_IsRefused() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+ ed.Document!.Blocks.Remove(tb);
+
+ Assert.False(ed.InsertTableRow(tb, 0));
+ Assert.Equal(2, tb.Rows);
+ });
+
+ [Fact]
+ public void AReadOnlyEditor_RefusesAll() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+ ed.IsReadOnly = true;
+
+ Assert.False(ed.InsertTableRow(tb, 0));
+ Assert.False(ed.DeleteTableRow(tb, 0));
+ Assert.False(ed.InsertRowBelow());
+ Assert.False(ed.DeleteColumn());
+
+ Assert.Equal(2, tb.Rows);
+ Assert.Equal(2, tb.Columns);
+ });
+
+ // ---- the caret shape --------------------------------------------------------------------------
+
+ [Fact]
+ public void TheCaretCommands_ActOnTheCaretsTable() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+
+ Assert.True(ed.InsertRowAbove());
+ Assert.Equal(3, tb.Rows);
+ Assert.True(ed.InsertColumnLeft());
+ Assert.Equal(3, tb.Columns);
+ Assert.True(ed.DeleteRow());
+ Assert.Equal(2, tb.Rows);
+ Assert.True(ed.DeleteColumn());
+ Assert.Equal(2, tb.Columns);
+ });
+
+ [Fact]
+ public void WithTheCaretOutsideATable_TheCaretCommandsDoNothing() => UiThread.Run(() =>
+ {
+ var ed = Editor(new TableBlock(2, 2));
+ var tb = Table(ed);
+ PlaceCaret(ed, ed.Document!.Blocks.OfType().First());
+
+ Assert.False(ed.InsertRowAbove());
+ Assert.False(ed.InsertRowBelow());
+ Assert.False(ed.DeleteRow());
+ Assert.False(ed.InsertColumnLeft());
+ Assert.False(ed.InsertColumnRight());
+ Assert.False(ed.DeleteColumn());
+
+ Assert.Equal(2, tb.Rows);
+ Assert.Equal(2, tb.Columns);
+ Assert.False(ed.IsModified);
+ });
+
+ // "Below" a cell that spans two rows is below the WHOLE merge: at r+1 the new row lands inside it, the
+ // merge grows over it, and only the other columns gain a row. Same rule as the context menu.
+ [Fact]
+ public void InsertRowBelow_GoesPastAVerticalMerge() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(3, 2);
+ built.MergeCells(0, 0, 1, 0);
+ var ed = Editor(built);
+ var tb = Table(ed);
+ Assert.Equal((1, 2), tb.SpanOf(0, 0)); // the fixture really is merged
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+
+ Assert.True(ed.InsertRowBelow());
+
+ // The new row is row 2 — after the merged area — so the merge still spans exactly rows 0..1.
+ Assert.Equal(4, tb.Rows);
+ Assert.Equal((1, 2), tb.SpanOf(0, 0));
+ Assert.False(tb.IsCovered(2, 0));
+ });
+
+ [Fact]
+ public void InsertColumnRight_GoesPastAHorizontalMerge() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(2, 3);
+ built.MergeCells(0, 0, 0, 1);
+ var ed = Editor(built);
+ var tb = Table(ed);
+ Assert.Equal((2, 1), tb.SpanOf(0, 0));
+ PlaceCaret(ed, tb.Cells[0][0].Para);
+
+ Assert.True(ed.InsertColumnRight());
+
+ Assert.Equal(4, tb.Columns);
+ Assert.Equal((2, 1), tb.SpanOf(0, 0));
+ Assert.False(tb.IsCovered(0, 2));
+ });
+
+ // Cells hold block lists, so tables nest. FindCell resolves the INNERMOST table — the caret commands
+ // must edit that one, not the table around it.
+ [Fact]
+ public void InANestedTable_TheCaretCommandsEditTheInnerTable() => UiThread.Run(() =>
+ {
+ var built = new TableBlock(1, 2);
+ built.Cells[0][0].Blocks.Add(new TableBlock(1, 2));
+ var ed = Editor(built);
+ var outer = Table(ed);
+ var inner = outer.Cells[0][0].Blocks.OfType().Single();
+ PlaceCaret(ed, inner.Cells[0][0].Para);
+
+ Assert.True(ed.InsertRowBelow());
+
+ Assert.Equal(2, inner.Rows);
+ Assert.Equal(1, outer.Rows);
+ });
+
+ // An inline table lives in a paragraph's inlines, not in any block list — the containment check walks
+ // there too (BlockWalk descends into inline tables), so its rows are editable like any other.
+ [Fact]
+ public void AnInlineTable_IsEditableToo() => UiThread.Run(() =>
+ {
+ var host = new Paragraph
+ {
+ Inlines = { new Run { Text = "x" }, new InlineTable { Table = new TableBlock(1, 2) }, new Run { Text = "y" } }
+ };
+ var ed = Editor(host);
+ var it = ed.Document!.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).Single();
+ PlaceCaret(ed, it.Table.Cells[0][0].Para);
+
+ Assert.True(ed.InsertTableRow(it.Table, 1));
+ Assert.True(ed.InsertRowBelow());
+
+ Assert.Equal(3, it.Table.Rows);
+ });
+}