From 670de8054336926d38d8720e6e8d6c35f3fdae41 Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 24 Sep 2026 07:00:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=91=9C=20=ED=96=89=C2=B7=EC=97=B4=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EA=B3=BC=20=EB=8B=A8=EC=B6=95=ED=82=A4=20?= =?UTF-8?q?=ED=91=9C=20=EA=B3=B5=EA=B0=9C=20=E2=80=94=20=EC=83=81=EB=A5=98?= =?UTF-8?q?=20PR=20#48=C2=B7#49=20=EB=B0=B1=ED=8F=AC=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 행/열 편집은 우클릭 메뉴로만, 단축키는 편집기 안에서만 읽혔다. 자체 툴바를 만드는 호스트는 TableBlock을 직접 고치거나(되돌리기·부모 배선·무효화 누락) "Ctrl+B"를 다시 적어야 했다. - 표·인덱스 지정 4종(InsertTableRow 등) + 캐럿 기준 6종(InsertRowAbove 등). 호출 하나 = 되돌리기 한 단계, bool 반환. 읽기 전용·범위 밖·다른 문서의 표·마지막 행/열은 false이고 체크포인트도 없다. "아래/오른쪽"은 메뉴와 같은 RowBelowIndex/ColumnRightIndex(병합 뒤). 문서 소속 판정은 AllParagraphs + FindCell — 부모 사슬은 떼어 낸 표도 "안"으로 본다(테스트로 고정). - RichEditorShortcuts·RichEditorShortcutId·RichEditorShortcut 공개. All은 읽기 전용 뷰, TryMatch는 내부. enum 순서는 포트 것 유지, Gesture(id)는 Avalonia 전용이라 뺐다. 테스트 887 → 910(TableStructureApiTests 17, PublicShortcutTableTests 6), 전부 통과. 반증 6종 전부 의도한 테스트만 빨강: 다른 문서 검사·범위 검사·읽기 전용 검사 제거, 아래=r+1, 키 핸들러 Bold 분기 제거, All이 배열 그대로. ControlFeatureFlagTests의 HTML 붙여넣기 테스트가 전체 실행 2/3에서 실패 — 실제 OS 클립보드에 HTML 형식이 안 실려 평문으로 떨어짐. main에서도 1/3 재현(기존 불안정, 이번 변경과 무관), 단독 3회 통과. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 ++ Project_Roadmap.md | 4 +- .../Controls/RichEditor.ContextMenu.cs | 34 +-- .../Controls/RichEditor.Input.cs | 70 ++--- .../Controls/RichEditor.Tables.cs | 120 +++++++- .../Controls/RichEditorShortcuts.cs | 193 ++++++++---- .../Controls/RichEditorToolbar.cs | 22 +- src/WinUIRichEditor/PublicAPI.Unshipped.txt | 66 +++- .../ControlFeatureFlagTests.cs | 2 +- .../PublicShortcutTableTests.cs | 94 ++++++ .../ShortcutTableTests.cs | 4 +- .../TableStructureApiTests.cs | 288 ++++++++++++++++++ 12 files changed, 779 insertions(+), 135 deletions(-) create mode 100644 tests/WinUIRichEditor.Tests/PublicShortcutTableTests.cs create mode 100644 tests/WinUIRichEditor.Tests/TableStructureApiTests.cs 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); + }); +}