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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ All notable changes to WinUIRichEditor. This project is a WinUI 3 + Win2D port o
the format follows [Keep a Changelog](https://keepachangelog.com/). The public API is frozen as of 1.0.0
and follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### 상류 라운드34 백포트 (2026-09-23, AvaloniaRichEditor PR #53)

상류가 테스트가 닿지 않던 파일을 감사해 고친 결함들을 이 포트에서 **먼저 측정**했다. 빨강이 된 것만 고쳤고,
이미 맞던 것은 아래에 적었다. 공개 표면은 추가 1(`RichEditorIcon.Quote`).

**보안**
- **붙여넣은 HTML이 다른 컴퓨터에 접속하게 만들 수 있었다.** 네트워크 공유를 가리키는 `<img src="file://host/share/…">`를
열어서 SMB 연결이 시작됐고, 사용자의 NTLM 인증 정보가 그 호스트에 제시될 수 있었다. 붙여넣기 기본값은 %TEMP%만
허용해 피했지만, 로컬 파일 그림을 허용한 호스트에서는 열렸다(21초 연결 시도 실측). 이제 설정과 무관하게
네트워크 공유는 읽지 않는다.
- **스크립트 링크(`javascript:`·`vbscript:`·`data:`)를 읽을 때 버린다.** 글자는 남는다. 내보낸 HTML과 클립보드
HTML로 다시 나가던 것이다. 대소문자·앞 공백·스킴 안 탭·HTML 엔티티 변형도 걸러진다.

**결함**
- 인라인 표/그림을 블록으로 바꾸면 캐럿이 문서 밖 문단(표)이나 문단 끝 너머(그림)에 남았다.
- 테두리로 잡은 표가 다른 곳을 우클릭해도 선택된 채 남아, 이어서 Delete를 누르면 그 표가 지워졌다.
- `AllowTables`/`AllowImages`가 꺼져도 Ctrl+끌기로 표·그림이 복사됐다(이동은 허용 유지).
- 아무것도 바꾸지 않는 명령이 빈 되돌리기 단계를 남기고 문서를 '수정됨'으로 만들었다 — 0에서 내어쓰기, 같은 정렬·
줄 간격·줄 높이, 목록 없는 문단의 목록 해제, 바꿀 것 없는 모두 바꾸기, 이미 적용된 여백, 이미 원본 크기인 그림.

**동작 변경(상류 사용자 결정)**
- 블록(표·그림·구분선)을 문단 **맨 앞**에서 넣으면 문단 앞에, **끝**에서 넣으면 뒤에 들어간다. 전에는 늘 문단을
나눠 빈 문단이 하나 생겼다. 중간이면 나누는 것은 그대로.
- 어느 표든 **마지막 셀에서 Tab**을 누르면 그 표에 행이 추가된다. 전에는 문서의 마지막 표만 늘고, 다른 표에서는
다음 표로 건너뛰었다.
- 툴바의 목록 버튼 옆에 **인용 버튼**. 기본 UI에는 인용을 켤 방법이 없었다.

**이미 맞던 것**(측정만): 메뉴의 표 삭제 뒤 캐럿 · 인라인 그림 키 삭제/잘라내기 · 선택 위 그림·엑셀 붙여넣기 ·
셀·빈 문단의 인용 막대 · 디코드 실패 그림의 자리 · 툴바 용지 동기화 · 다른 앱용 HTML의 문단 여백 ·
`ReplaceNext`/`ReplaceAll`/`InsertDivider`의 읽기 전용 검사.

## [1.2.0] - 2026-09-20

1.0 동결 이후 첫 기능 릴리스. **공개 API는 추가만**(의존성 속성 7개) — breaking 없음, 코드 수정 없이 올릴 수 있다.
Expand Down
16 changes: 15 additions & 1 deletion Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,21 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
프레임워크의 라우팅·히트테스트·포커스뿐이고, 그건 여전히 `fault-sweep` + 실기의 몫이다.
2. **실기에만 있는 검증 항목**: AltGr 자판(처리기 배선은 `KeyRoutedEventArgs`를 만들 수 없어 자동 검증 밖),
IME 조합, 포커스·캐럿 깜빡임.
3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조.
3. **상류 백포트 잔여**: 라운드31~32 이후 상류 변경분 대조. 라운드34(상류 PR #53)는 2026-09-23 옮김(아래 절).
**남은 것**: PR #50~#52의 여백 후속(`Block.AutoTopMargin` 등 — 들어오면 여백 메뉴의 "자동(한 줄)" 항목도 함께),
그림 손잡이 vs 열 경계 우선순위, 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`.
4. 아래 "알려진 한계"의 미수정 항목.

### 상류 라운드34 백포트 (2026-09-23) — 테스트 832 → 862, 결함 14(보안 2 포함) + 동작 변경 3, 반증 완료
상류 결함 32건과 결정 4건을 **이 포트에서 먼저 측정**했다. 빨강이 된 것만 고쳤다(상세는 CHANGELOG).
- **보안 2건**: UNC 그림(SMB/NTLM), 스크립트 링크 — 둘 다 포트에도 있었다.
- 이미 맞던 것이 많다 — 포트는 블록 위치 맵으로 그리고(디코드 실패 그림이 자리를 지킴), 인용 막대를 한 함수로 그리며,
인라인 그림 삭제가 캐럿을 옮긴다. 대응 코드가 없는 것: 래스터 PDF 채널 순서(포트는 Print to PDF), `IsCellOf`.
- ⚠ **헛도는 테스트 둘을 잡았다**: ① 맨 앞 삽입을 볼 때 문서 첫 빈 문단을 걸러 내 포트의 빈 머리 문단까지 가림
② 캐럿이 문서 안인지를 **부모 사슬**로 봄 — 떨어져 나간 노드도 옛 부모를 기억해 "문서 안"으로 나온다. 문서의 문단
목록(`AllParagraphs`)으로 볼 것.
- 여백 메뉴 클릭을 `PickMargin`으로 분리(메뉴 항목의 Click은 코드에서 일으킬 수 없다).

### 포인터 파이프라인 (2026-09-20) — 1~3단계 완료, 테스트 815 → 832, 결함 1건(포트 전용), 반증 16종
포인터 처리기가 이벤트에서 읽는 것은 **위치·오른쪽 버튼·모디파이어·캡처** 넷뿐이다. 그것을 `PointerStep`으로
묶고 캡처를 `IPointerCapture`로 추상화해(`RichEditor.PointerPipeline.cs`) 처리기를 어댑터로 줄였다. 테스트는
Expand Down Expand Up @@ -167,6 +179,8 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
`AllowImages`/`AllowTables`는 **추가**를 막는 플래그다.
- **`TargetProperty`는 `object`로 등록** — `RichEditor`로 형식을 두면 `{Binding}`이 null을 넣는다(측정).
바인딩 엔진이 XAML 형식 메타데이터로 대입을 검사하는데, 앱은 자기 마크업이 이름을 댄 형식에만 그것을 만든다.
- **편집 중 링크는 Ctrl+클릭으로 연다**(Word 방식). 상류는 일반 클릭 — 상류 사용자 결정(2026-09-23)으로 유지되는 알려진 분기.
- **붙여넣은/읽은 HTML의 스크립트 링크는 읽을 때 버리고, 네트워크 공유 `file://` 그림은 설정과 무관하게 읽지 않는다**(상류와 같음).
- 렌더 백엔드 **Win2D** · 패키징 **Unpackaged**(MSIX 아님) · **Native AOT** 게시 가능 · 컨트롤은 **XAML 없는 코드 전용**.

## 보류 / 백로그
Expand Down
17 changes: 17 additions & 0 deletions src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,23 @@ private void InsertBlockAtCaret(Block block)
int idx = container.IndexOf(p);
if (idx < 0) return;

// At the paragraph's start the block goes BEFORE it, at its end AFTER it; only in between does the paragraph
// split. Always splitting left an empty paragraph ahead of (or behind) the block (upstream round 34 — the
// rule DropBlock already had). An empty paragraph takes the block after it, as before.
int len = GetParagraphLength(p);
int off = Math.Clamp(_caret.Offset, 0, len);
if (len > 0 && (off == 0 || off == len))
{
block.Parent = p.Parent;
container.Insert(off == 0 ? idx : idx + 1, block);
UpdateParents(Document); // NormalizeBlocks puts a paragraph after a block that ends the list
int bi = container.IndexOf(block);
var landing = bi + 1 < container.Count && container[bi + 1] is Paragraph next ? next : p;
_caret = new TextPointer(landing, 0);
CollapseSelectionToCaret();
return;
}

int splitIdx = SplitInlinesAt(p, _caret.Offset);
// The tail is the same paragraph's continuation, so it keeps the FULL paragraph format
// (the old two-field copy dropped list/heading/spacing/quote across a block insert).
Expand Down
15 changes: 14 additions & 1 deletion src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ private MenuFlyout BuildContextMenuCore(Point pos)
// Right-clicking outside an existing selection moves the caret there first (Word/VS behavior).
// Only when no object was hit — those select the object instead.
bool onObject = hitBlockImage != null || hitInlineImage != null || hitInlineTable != null || edgeTable != null;
// Not on an object: one selected earlier (a table held by its border) lets go, as a left press does. It
// stayed selected while the caret moved to the text, and Delete then removed it (upstream round 34; the
// read-only branch below already dropped it).
if (!onObject && HasBlockSelection) { ClearObjectSelection(); InvalidateCanvas(); }
// On a link, the caret goes just past the character UNDER the pointer — inside the link — so the link
// menu and its caret-based actions (open/edit/remove) act on the link the pointer is on. The nearest
// boundary alone put the left half of a link's first character before the link (the text menu) and a
Expand Down Expand Up @@ -467,7 +471,7 @@ MenuFlyoutSubItem Side(string label, Func<double> get, Action<double> set)
{
double vv = v;
var ri = new RadioMenuFlyoutItem { Text = $"{vv:0} px", GroupName = label, IsChecked = Math.Abs(get() - vv) < 0.5, FontSize = MenuFontSize };
ri.Click += (_, _) => { if (Document != null) PushUndo(null); set(vv); AfterFormat(); };
ri.Click += (_, _) => PickMargin(get, set, vv);
s.Items.Add(ri);
}
return s;
Expand All @@ -480,6 +484,15 @@ MenuFlyoutSubItem Side(string label, Func<double> get, Action<double> set)
return sub;
}

// A margin preset's click (split out so a test can reach it — a flyout item cannot be clicked from code).
private void PickMargin(Func<double> get, Action<double> set, double v)
{
if (get().Equals(v)) return; // already in force: no undo step that undoes nothing (as the cell v-align radio)
if (Document != null) PushUndo(null);
set(v);
AfterFormat();
}

// Cell vertical-alignment radio submenu for the anchor cell at (r,c) — Top/Center/Bottom, checked
// at the current value. Row heights are unchanged (alignment redistributes slack), so a repaint
// plus the undo checkpoint is all that's needed.
Expand Down
12 changes: 11 additions & 1 deletion src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ internal void DragObjectMoved(Point docPt, bool ctrl)
_dragObjectActive = true;
}
var tp = GetPositionFromPoint(docPt);
_dropPreview = tp != null && CanDropObject(_dragObject, tp, copy: ctrl) ? tp : null;
_dropPreview = tp != null && CanDropObject(_dragObject, tp, copy: ctrl) && (!ctrl || MayCreate(_dragObject)) ? tp : null;
SetCursorShape(_dropPreview != null ? InputSystemCursorShape.Arrow : InputSystemCursorShape.UniversalNo);
InvalidateCanvas();
}
Expand Down Expand Up @@ -112,11 +112,21 @@ internal static bool CanDropObject(object obj, TextPointer at, bool copy)
return table == null || !BlockContains(table, p);
}

// A copy CREATES a table or picture, which AllowTables / AllowImages forbid (as they do for the menu and for
// paste); a move only relocates one the document already has (upstream round 34).
private bool MayCreate(object obj) => obj switch
{
TableBlock or InlineTable => AllowTables,
ImageBlock or InlineImage => AllowImages,
_ => true,
};

// Moves (or copies) obj to `at`. Returns whether the document changed: a drop where the object already
// is, or one CanDropObject refuses, is no edit at all — no undo step, not "modified".
internal bool DropObject(object obj, TextPointer at, bool copy)
{
if (Document == null || at.Paragraph is not { } p || !CanDropObject(obj, at, copy)) return false;
if (copy && !MayCreate(obj)) return false;
return obj switch
{
ImageBlock or TableBlock => DropBlock((Block)obj, p, at.Offset, copy),
Expand Down
4 changes: 3 additions & 1 deletion src/WinUIRichEditor/Controls/RichEditor.FindReplace.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ public int ReplaceAll(string query, string replacement, bool matchCase)
{
if (!AllowFindReplace || IsReadOnly || Document == null || string.IsNullOrEmpty(query)) return 0;
var paras = AllParagraphs();
if (paras.Count == 0) return 0;
var cmp = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
// Nothing to replace: no undo step and no "modified" flag (both came from the checkpoint below).
if (!paras.Exists(p => BuildPlain(p).Contains(query, cmp))) return 0;
PushUndo(null);
_caret = new TextPointer(paras[0], 0);
CollapseSelectionToCaret();
Expand Down
8 changes: 8 additions & 0 deletions src/WinUIRichEditor/Controls/RichEditor.Formatting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ public void ClearFormatting() => ApplyStyleToSelection(r =>
public void SetTextAlignment(TextAlignment align)
{
if (_caret.Paragraph == null || IsReadOnly) return;
if (SelectedParagraphs().All(p => p.TextAlignment == align)) return; // no undo step that undoes nothing
PushUndo(null);
foreach (var p in SelectedParagraphs()) p.TextAlignment = align;
AfterFormat();
Expand Down Expand Up @@ -197,6 +198,8 @@ public void SetHeading(int level)
public void SetLineSpacing(double spacing)
{
if (_caret.Paragraph == null || IsReadOnly) return;
// Equals: NaN matches NaN. Nothing to change -> no undo step that undoes nothing (upstream round 34).
if (SelectedParagraphs().All(p => p.LineSpacing.Equals(spacing) && double.IsNaN(p.LineHeight))) return;
PushUndo(null);
foreach (var p in SelectedParagraphs()) { p.LineSpacing = spacing; p.LineHeight = double.NaN; }
AfterFormat();
Expand All @@ -207,6 +210,7 @@ public void SetLineSpacing(double spacing)
public void SetLineHeight(double height)
{
if (_caret.Paragraph == null || IsReadOnly) return;
if (SelectedParagraphs().All(p => p.LineHeight.Equals(height) && double.IsNaN(p.LineSpacing))) return; // see SetLineSpacing
PushUndo(null);
foreach (var p in SelectedParagraphs()) { p.LineHeight = height; p.LineSpacing = double.NaN; }
AfterFormat();
Expand Down Expand Up @@ -256,6 +260,8 @@ public void ToggleQuote()
public void Indent(double delta)
{
if (_caret.Paragraph == null || IsReadOnly) return;
// Outdent at 0 (or indent at the cap) changes nothing: no undo step that undoes nothing.
if (SelectedParagraphs().All(p => Math.Clamp(p.Indent + delta, 0, 400) == p.Indent)) return;
PushUndo(null);
foreach (var p in SelectedParagraphs()) p.Indent = Math.Clamp(p.Indent + delta, 0, 400);
AfterFormat();
Expand All @@ -277,6 +283,8 @@ public void Indent(double delta)
public void RemoveList()
{
if (_caret.Paragraph == null || IsReadOnly) return;
// No list anywhere in it: no undo step that undoes nothing.
if (SelectedParagraphs().All(p => p.ListType == ListKind.None && p.ListMarker == ListMarkerStyle.Default && p.ListLevel == 0)) return;
PushUndo(null);
foreach (var p in SelectedParagraphs()) ClearList(p);
AfterFormat();
Expand Down
9 changes: 5 additions & 4 deletions src/WinUIRichEditor/Controls/RichEditor.Images.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ internal static (double w, double h) BlockImageDims(ImageBlock img)
private void ResetBlockImageNatural(ImageBlock img)
{
if (NaturalImageSize(img.RawBytes) is not { } nat) return;
PushUndo(null);
double w = nat.w, h = nat.h;
double maxW = Math.Max(50, _layoutWidth - 40);
if (w > maxW) { h *= maxW / w; w = maxW; }
if (img.Width == w && img.Height == h) return; // already: no undo step that undoes nothing
PushUndo(null);
img.Width = w; img.Height = h;
AfterEdit();
}
Expand All @@ -71,10 +72,11 @@ private void ScaleBlockImage(ImageBlock img, double factor)
private void ResetInlineImageNatural(InlineImage img)
{
if (NaturalImageSize(img.RawBytes) is not { } nat) return;
PushUndo(null);
double w = nat.w, h = nat.h;
double maxW = Math.Max(40, Math.Min(_layoutWidth - 40, 240));
if (w > maxW) { h *= maxW / w; w = maxW; }
if (img.Width == w && img.Height == h) return; // see ResetBlockImageNatural
PushUndo(null);
img.Width = w; img.Height = h;
_tableRowHeights.Clear();
AfterEdit();
Expand Down Expand Up @@ -371,8 +373,7 @@ internal void ConvertInlineImageToBlock(Paragraph host, InlineImage src)
var blk = new ImageBlock { Width = w, Height = h };
blk.SetImageData(bytes, src.MimeType ?? ImageMime.Detect(bytes));

host.Inlines.Remove(src);
if (host.Inlines.Count == 0) host.Inlines.Add(new Run { Text = "" });
RemoveInlineCharacter(host, src);
container.Insert(idx + 1, blk);
UpdateParents(Document);

Expand Down
Loading
Loading