Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
4 changes: 2 additions & 2 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 반증 완료
Expand Down Expand Up @@ -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()`가 캐시를 **전부** 비운다 — 바로 위 주석은 "가장 오래된 것만"이라고 말한다(주석/코드 불일치).
Expand Down
34 changes: 17 additions & 17 deletions src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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+,"),
Expand All @@ -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)),
Expand Down Expand Up @@ -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"));
Expand All @@ -362,21 +362,21 @@ 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);
ri.Click += (_, _) => SetTextAlignment(a);
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)
{
Expand All @@ -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;
Expand Down Expand Up @@ -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).
Expand Down
70 changes: 35 additions & 35 deletions src/WinUIRichEditor/Controls/RichEditor.Input.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading
Loading