diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7a5e60..515397a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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이 다른 컴퓨터에 접속하게 만들 수 있었다.** 네트워크 공유를 가리키는 ``를
+ 열어서 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 없음, 코드 수정 없이 올릴 수 있다.
diff --git a/Project_Roadmap.md b/Project_Roadmap.md
index 1576cc6..9014283 100644
--- a/Project_Roadmap.md
+++ b/Project_Roadmap.md
@@ -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`) 처리기를 어댑터로 줄였다. 테스트는
@@ -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 없는 코드 전용**.
## 보류 / 백로그
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs b/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs
index bb664ca..219e416 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs
@@ -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).
diff --git a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
index 5019b51..1887e30 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
@@ -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
@@ -467,7 +471,7 @@ MenuFlyoutSubItem Side(string label, Func get, Action 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;
@@ -480,6 +484,15 @@ MenuFlyoutSubItem Side(string label, Func get, Action 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 get, Action 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.
diff --git a/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs b/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs
index 21d5d81..04dc7a9 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.DragBlock.cs
@@ -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();
}
@@ -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),
diff --git a/src/WinUIRichEditor/Controls/RichEditor.FindReplace.cs b/src/WinUIRichEditor/Controls/RichEditor.FindReplace.cs
index b683cdc..724b138 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.FindReplace.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.FindReplace.cs
@@ -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();
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Formatting.cs b/src/WinUIRichEditor/Controls/RichEditor.Formatting.cs
index 63ddc6d..7224592 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Formatting.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Formatting.cs
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Images.cs b/src/WinUIRichEditor/Controls/RichEditor.Images.cs
index 6c9e953..200a817 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Images.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Images.cs
@@ -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();
}
@@ -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();
@@ -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);
diff --git a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs
index a768638..76dafa1 100644
--- a/src/WinUIRichEditor/Controls/RichEditor.Tables.cs
+++ b/src/WinUIRichEditor/Controls/RichEditor.Tables.cs
@@ -465,20 +465,23 @@ private void HandleTab(bool shift)
int idx = all.IndexOf(current);
if (idx < 0) return;
+ // The outermost table around the caret (through nested and in-cell inline tables); nested tables don't
+ // grow via Tab — use the right-click menu.
+ var top = tb;
+ while (EnclosingTableOf(top) is { } up) top = up;
+
if (shift)
{
if (idx > 0) FocusCell(all[idx - 1].Para); // else: first cell of the document -> no-op
}
- else if (idx + 1 < all.Count)
+ else if (idx + 1 < all.Count && IsWithin(all[idx + 1], top))
{
FocusCell(all[idx + 1].Para);
}
else
{
- // Past the document's last cell: add a row to the enclosing TOP-LEVEL table (nested tables
- // grow via the right-click menu, not Tab), walking up the parent chain if the cell is nested.
- var top = tb;
- while (top.Parent is TableCell pcell && pcell.Parent is TableBlock gp) top = gp;
+ // Past this table's last cell: add a row to it (Word/HWP). Only the document's last table used to
+ // grow — from any other, Tab jumped into the next table below (upstream round 34, user decision).
PushUndo(null);
top.InsertRow(top.Rows);
if (Document != null) UpdateParents(Document);
@@ -488,6 +491,31 @@ private void HandleTab(bool shift)
}
}
+ // Takes one inline object's character (a picture, an inline table) out of `p` and returns the offset it sat at.
+ // Caret and selection ends past it are pulled back one: they kept their offsets, so a caret after a trailing
+ // object sat one past the paragraph's end and the next keys went nowhere (upstream round 34).
+ private int RemoveInlineCharacter(Paragraph p, Inline obj)
+ {
+ int idx = p.Inlines.IndexOf(obj), off = 0;
+ for (int i = 0; i < idx; i++) off += InlineLen(p.Inlines[i]);
+ p.Inlines.Remove(obj);
+ if (p.Inlines.Count == 0) p.Inlines.Add(new Run { Text = "" });
+ TextPointer Back(TextPointer t) => ReferenceEquals(t.Paragraph, p) && t.Offset > off ? new TextPointer(p, t.Offset - 1) : t;
+ _caret = Back(_caret);
+ _selStart = Back(_selStart);
+ _selEnd = Back(_selEnd);
+ return off;
+ }
+
+ // Whether `e` is `ancestor` or lies inside it, through the parent chain (cells, nested and inline tables).
+ // Only for nodes in the document: a detached subtree keeps its Parent links.
+ private static bool IsWithin(object? e, object ancestor)
+ {
+ for (object? cur = e; cur != null; cur = (cur as TextElement)?.Parent)
+ if (ReferenceEquals(cur, ancestor)) return true;
+ return false;
+ }
+
// Selects the whole content of a cell (caret at end), redirecting covered cells to their merge anchor.
private void FocusCell(Paragraph cell)
{
@@ -852,8 +880,12 @@ internal void ConvertInlineTableToBlock(Paragraph host, InlineTable it)
PushUndo(null);
var tb = (TableBlock)it.Table.Clone();
- host.Inlines.Remove(it);
- if (host.Inlines.Count == 0) host.Inlines.Add(new Run { Text = "" });
+ // The caret (and a whole-table selection) may be in the inline original's cells, which leave the document
+ // with it — typing went nowhere (upstream round 34). It goes where the table was.
+ bool caretInside = IsWithin(_caret.Paragraph, it.Table)
+ || IsWithin(_selStart.Paragraph, it.Table) || IsWithin(_selEnd.Paragraph, it.Table);
+ int off = RemoveInlineCharacter(host, it);
+ if (caretInside) _caret = new TextPointer(host, off);
container.Insert(idx + 1, tb);
UpdateParents(Document);
diff --git a/src/WinUIRichEditor/Controls/RichEditorIcons.cs b/src/WinUIRichEditor/Controls/RichEditorIcons.cs
index de989db..960a9ef 100644
--- a/src/WinUIRichEditor/Controls/RichEditorIcons.cs
+++ b/src/WinUIRichEditor/Controls/RichEditorIcons.cs
@@ -114,6 +114,8 @@ public enum RichEditorIcon
FontSizeDecrease,
/// Find / replace.
Find,
+ /// Quote / blockquote toggle (the toolbar's quote button).
+ Quote,
}
/// Host-pluggable icon factory for the built-in chrome (toolbar buttons and context menus).
diff --git a/src/WinUIRichEditor/Controls/RichEditorToolbar.cs b/src/WinUIRichEditor/Controls/RichEditorToolbar.cs
index 2ca0e0e..9d4683c 100644
--- a/src/WinUIRichEditor/Controls/RichEditorToolbar.cs
+++ b/src/WinUIRichEditor/Controls/RichEditorToolbar.cs
@@ -120,6 +120,7 @@ public Func>? ImagePicker
// null-guard every access. Null = "not built at the current ToolbarLevel".
private ToggleButton? _bold, _italic, _underline, _strike, _painter;
private Button? _bullet, _number; // list-box icon buttons (toggle the list)
+ private Button? _quote; // quote toggle
private TextBlock? _bulletPreview, _numberPreview; // current list marker shown in the list boxes
[ThreadStatic] private static SolidColorBrush? _dimInk; // per UI thread, as _activeBrush
private static SolidColorBrush DimInk => _dimInk ??= new(Color.FromArgb(255, 0xBF, 0xC3, 0xC7)); // inactive marker
@@ -337,7 +338,7 @@ private UIElement Build()
// Reset reflected controls; only the ones the current level/state re-builds are re-assigned. Sync()
// null-guards each, so a Minimal or read-only toolbar (a subset) reflects safely.
_bold = _italic = _underline = _strike = _painter = null;
- _bullet = _number = _undo = _redo = _tableBtn = _imageBtn = _dividerBtn = _findBtn = null;
+ _bullet = _number = _quote = _undo = _redo = _tableBtn = _imageBtn = _dividerBtn = _findBtn = null;
_bulletPreview = _numberPreview = null;
_font = _size = _heading = _align = null; _fontReflected = null;
_spacingBox = null; _colorSwatch = _highlightSwatch = null;
@@ -454,7 +455,10 @@ private UIElement Build()
(ListMarkerStyle.LowerAlpha, "a)"), (ListMarkerStyle.UpperAlpha, "A)"), (ListMarkerStyle.LowerRoman, "i)"));
_number = number.Icon; _numberPreview = number.Preview;
Add(number.Box);
- // Quote is via the right-click menu / ToggleQuote(); no toolbar button (matches original).
+ // Quote beside the lists: without it the default UI had no way to set one (the right-click item
+ // 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(BuildLineSpacingControl());
@@ -748,6 +752,7 @@ private void Sync()
if (_strike != null) SetActive(_strike, f.Strike);
if (_bullet != null) SetActive(_bullet, f.List == ListKind.Bullet);
if (_number != null) SetActive(_number, f.List == ListKind.Ordered);
+ if (_quote != null) SetActive(_quote, f.Quote);
if (_painter != null) SetActive(_painter, rt.IsFormatPainterActive);
// List previews show the caret paragraph's current marker, full-ink when that list kind is
diff --git a/src/WinUIRichEditor/Controls/ToolbarIcons.cs b/src/WinUIRichEditor/Controls/ToolbarIcons.cs
index 99e7d1c..e7add31 100644
--- a/src/WinUIRichEditor/Controls/ToolbarIcons.cs
+++ b/src/WinUIRichEditor/Controls/ToolbarIcons.cs
@@ -100,6 +100,10 @@ or RichEditorIcon.Strikethrough or RichEditorIcon.CharacterFormat
RichEditorIcon.IndentDecrease => Build(box,
("M4 6 H20 M4 18 H20 M11 12 H20", false),
("M8 9 L4 12 L8 15 Z", true)),
+ // The quote bar the editor draws, beside the lines it marks (same path as upstream's).
+ RichEditorIcon.Quote => Build(box,
+ ("M5 5 V19", false),
+ ("M9 7 H20 M9 12 H20 M9 17 H16", false)),
RichEditorIcon.InsertTable => Build(box,
("M3 5 H21 V19 H3 Z M3 11 H21 M3 15 H21 M9 5 V19 M15 5 V19", false)),
RichEditorIcon.InsertImage => Build(box,
diff --git a/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs b/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
index af7418e..394c94a 100644
--- a/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
+++ b/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
@@ -214,7 +214,7 @@ void TakeSpace()
if (name == "a")
{
var href = child.GetAttributeValue("href", "");
- if (!string.IsNullOrEmpty(href)) childLink = href;
+ if (!string.IsNullOrEmpty(href)) childLink = SafeHref(href);
}
bool hasLink = !string.IsNullOrEmpty(childLink);
@@ -669,7 +669,13 @@ private static (byte[]?, double, double, string?) LoadImage(HtmlNode node)
// file:, or every HWP/Word picture reference silently misses this branch.
if (src.StartsWith("ms-clipboard-file:", StringComparison.OrdinalIgnoreCase))
src = "file:" + src.Substring("ms-clipboard-file:".Length);
- var path = new Uri(src).LocalPath;
+ var uri = new Uri(src);
+ // A file on ANOTHER machine (file://host/share/…) is a UNC path, and touching it opens an SMB
+ // connection that offers the user's NTLM credentials to that host — from a paste, with an
+ // the copied page chose. Local file images are about this machine; a network share is never read,
+ // whatever the flags say (upstream round 34; measured here: a 21 s connect attempt).
+ if (uri.IsUnc) return (null, 0, 0, null);
+ var path = uri.LocalPath;
// Even when local files are blocked (the paste default), allow paths under %TEMP% — when the
// caller takes the paste exemption: Word/HWP CF_HTML reference the pictures the COPY itself
// just wrote there, and refusing them silently drops every image pasted from those apps.
@@ -701,6 +707,24 @@ private static bool IsTempPath(string path)
catch (Exception ex) { RichEditorDiagnostics.Report(ex); return false; }
}
+ // A link's address, or null for a script link (javascript:, vbscript:, data:). The text stays; only the link
+ // goes. Kept, a pasted page's script link was written back out as an into exported and clipboard HTML
+ // (upstream round 34, user decision: drop on read). Checked the way a browser reads a scheme: entities decoded,
+ // case ignored, whitespace and control characters (a tab inside "java\tscript:") not counted.
+ internal static string? SafeHref(string href)
+ {
+ var sb = new StringBuilder();
+ foreach (char ch in HtmlEntity.DeEntitize(href))
+ {
+ if (ch <= ' ') continue;
+ if (ch == ':') break;
+ sb.Append(char.ToLowerInvariant(ch));
+ if (sb.Length > 16) break; // longer than any scheme below
+ }
+ string scheme = sb.ToString();
+ return scheme is "javascript" or "vbscript" or "data" ? null : href;
+ }
+
private static double ReadPx(HtmlNode node, string attr, string cssProp)
{
var a = node.GetAttributeValue(attr, "").Trim();
@@ -764,7 +788,7 @@ private static void ParseInlineNode(HtmlNode child, Paragraph p, FontWeight weig
if (name == "a")
{
var href = child.GetAttributeValue("href", "");
- if (!string.IsNullOrEmpty(href)) cu = href;
+ if (!string.IsNullOrEmpty(href)) cu = SafeHref(href);
}
bool childOwnColor = ownColor || child.GetAttributeValue("data-are-fg", "") == "1";
diff --git a/src/WinUIRichEditor/PublicAPI.Unshipped.txt b/src/WinUIRichEditor/PublicAPI.Unshipped.txt
index 5f28270..0d22aa6 100644
--- a/src/WinUIRichEditor/PublicAPI.Unshipped.txt
+++ b/src/WinUIRichEditor/PublicAPI.Unshipped.txt
@@ -1 +1 @@
-
\ No newline at end of file
+WinUIRichEditor.Controls.RichEditorIcon.Quote = 49 -> WinUIRichEditor.Controls.RichEditorIcon
diff --git a/tests/WinUIRichEditor.Tests/Round34BackportTests.cs b/tests/WinUIRichEditor.Tests/Round34BackportTests.cs
new file mode 100644
index 0000000..c2513b1
--- /dev/null
+++ b/tests/WinUIRichEditor.Tests/Round34BackportTests.cs
@@ -0,0 +1,294 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+using WinUIRichEditor.Documents;
+using WinUIRichEditor.Formatters;
+using Xunit;
+
+namespace WinUIRichEditor.Tests;
+
+/// Upstream round 34 (AvaloniaRichEditor PR #53, 2026-09-23), measured here before porting: each case
+/// was red in this port first, or is recorded as already right.
+public class Round34BackportTests
+{
+ // ---- security: HTML ingestion ------------------------------------------------------------------
+
+ // A file: image on ANOTHER machine is a UNC path; File.Exists on it opens an SMB connection that offers the
+ // user's NTLM credentials to that host. This port blocks local files on paste by default (temp only), but a
+ // host that allows local file images read network shares too. 192.0.2.1 is TEST-NET-1: an attempt shows up
+ // as the connect timeout.
+ [Theory]
+ [InlineData("file://192.0.2.1/share/pic.png")]
+ [InlineData("ms-clipboard-file://192.0.2.1/share/pic.png")]
+ public void AnImageOnANetworkShare_IsNeverOpened(string src)
+ {
+ var sw = Stopwatch.StartNew();
+ var doc = HtmlDocumentFormatter.ParseHtml($"
");
+
+ var run = doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).First(r => r.Text == "click");
+ Assert.Equal(href, run.NavigateUri);
+ }
+}
+
+/// Round 34's control-level cases, measured in this port (see ).
+[Collection(UiTests.Collection)]
+public class Round34BackportControlTests
+{
+ private const System.Reflection.BindingFlags NP = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
+
+ private static Paragraph P(string text) => new() { Inlines = { new Run { Text = text } } };
+
+ private static Controls.RichEditor Editor(params Block[] blocks)
+ {
+ var doc = new FlowDocument();
+ foreach (var b in blocks) doc.Blocks.Add(b);
+ return new Controls.RichEditor { Document = doc };
+ }
+
+ private static void Caret(Controls.RichEditor ed, Paragraph p, int offset)
+ {
+ foreach (var f in new[] { "_caret", "_selStart", "_selEnd" })
+ typeof(Controls.RichEditor).GetField(f, NP)!.SetValue(ed, new TextPointer(p, offset));
+ }
+
+ private static string Text(Block b) => b is Paragraph p ? string.Concat(p.Inlines.OfType().Select(r => r.Text)) : b.GetType().Name;
+
+ // A command that changes nothing left an undo step that undid nothing (and flagged the document modified).
+ [Theory]
+ [InlineData("outdent")]
+ [InlineData("align")]
+ [InlineData("spacing")]
+ [InlineData("height")]
+ [InlineData("removelist")]
+ [InlineData("replaceall")]
+ public void ACommandThatChangesNothing_LeavesNoUndoStep(string cmd) => UiThread.Run(() =>
+ {
+ var p = P("hello world");
+ var ed = Editor(p);
+ Caret(ed, p, 2);
+ ed.MarkSaved();
+ Assert.False(ed.CanUndo); // precondition
+
+ switch (cmd)
+ {
+ case "outdent": ed.Indent(-20); break;
+ case "align": ed.SetTextAlignment(p.TextAlignment); break;
+ case "spacing": ed.SetLineSpacing(p.LineSpacing); break;
+ case "height": ed.SetLineHeight(p.LineHeight); break;
+ case "removelist": ed.RemoveList(); break;
+ case "replaceall": Assert.Equal(0, ed.ReplaceAll("absent", "x", false)); break;
+ }
+
+ Assert.False(ed.CanUndo);
+ Assert.False(ed.IsModified);
+ });
+
+ [Fact]
+ public void ACommandThatChangesSomething_StillLeavesAnUndoStep() => UiThread.Run(() =>
+ {
+ var p = P("hello world");
+ var ed = Editor(p);
+ Caret(ed, p, 2);
+ ed.SetTextAlignment(Microsoft.UI.Xaml.TextAlignment.Center);
+ Assert.True(ed.CanUndo);
+ });
+
+ // Upstream decision (2026-09-23): a block goes where the caret is — in the middle the paragraph splits, at its
+ // start the block goes before it, at its end after it. This port always split, leaving an EMPTY paragraph ahead
+ // of a block inserted at a paragraph's start, and behind one inserted at its end.
+ [Theory]
+ [InlineData(4, new[] { "top", "abcd", "DividerBlock", "efgh", "next" })]
+ [InlineData(0, new[] { "top", "DividerBlock", "abcdefgh", "next" })]
+ [InlineData(8, new[] { "top", "abcdefgh", "DividerBlock", "next" })]
+ public void ADividerGoesWhereTheCaretIs(int offset, string[] expected) => UiThread.Run(() =>
+ {
+ var p = P("abcdefgh");
+ // Paragraphs on both sides, so NormalizeBlocks has nothing to add and an extra empty one is the defect.
+ var ed = Editor(P("top"), p, P("next"));
+ Caret(ed, p, offset);
+
+ ed.InsertDivider();
+
+ Assert.Equal(expected, ed.Document!.Blocks.Select(Text).ToArray());
+ });
+
+ // Upstream decision (2026-09-23): Tab in a table's last cell adds a row to THAT table; it jumped into the next
+ // table below unless the table was the document's last.
+ [Fact]
+ public void TabInTheLastCell_OfAnEarlierTable_AddsARow() => UiThread.Run(() =>
+ {
+ var first = new TableBlock(2, 2);
+ var second = new TableBlock(2, 2);
+ var ed = Editor(P("a"), first, P("between"), second);
+ Caret(ed, first.Cells[1][1].Para, 0);
+
+ typeof(Controls.RichEditor).GetMethod("HandleTab", NP)!.Invoke(ed, new object[] { false });
+
+ Assert.Equal(3, first.Rows);
+ Assert.Equal(2, second.Rows);
+ });
+
+ // Ctrl+drag CREATES a table or picture, which AllowTables / AllowImages forbid.
+ [Fact]
+ public void CopyDraggingATable_WithTablesOff_AddsNoTable() => UiThread.Run(() =>
+ {
+ var tb = new TableBlock(1, 1);
+ var target = P("drop here");
+ var ed = Editor(P("top"), tb, target);
+ ed.AllowTables = false;
+
+ ed.DropObject(tb, new TextPointer(target, 9), copy: true);
+
+ Assert.Single(ed.Document!.Blocks.OfType());
+ });
+
+ [Fact]
+ public void MoveDraggingATable_WithTablesOff_StillMovesIt() => UiThread.Run(() =>
+ {
+ var tb = new TableBlock(1, 1);
+ var target = P("drop here");
+ var ed = Editor(P("top"), tb, target);
+ ed.AllowTables = false;
+
+ Assert.True(ed.DropObject(tb, new TextPointer(target, 9), copy: false));
+ Assert.Single(ed.Document!.Blocks.OfType());
+ });
+
+ private static readonly byte[] Png = System.Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
+
+ private static TextPointer CaretOf(Controls.RichEditor ed) => (TextPointer)typeof(Controls.RichEditor).GetField("_caret", NP)!.GetValue(ed)!;
+
+ // By the document's own paragraph walk: a detached subtree keeps its Parent links, so climbing them from the
+ // caret would still "reach" the document (that made the first draft of the test below pass vacuously).
+ private static bool InDocument(Controls.RichEditor ed, Paragraph? p)
+ => p != null && ((System.Collections.Generic.IEnumerable)typeof(Controls.RichEditor)
+ .GetMethod("AllParagraphs", NP)!.Invoke(ed, null)!).Contains(p);
+
+ // Making an inline picture a block took its character out but left a caret that sat after it one past the
+ // paragraph's end (upstream round 34).
+ [Fact]
+ public void MakingAnInlinePictureABlock_KeepsTheCaretInsideItsParagraph() => UiThread.Run(() =>
+ {
+ var img = new InlineImage { Width = 16, Height = 16 };
+ img.SetImageData(Png, "image/png");
+ var p = new Paragraph { Inlines = { new Run { Text = "ab" }, img } };
+ var ed = Editor(p, P("next"));
+ Caret(ed, p, 3); // after the picture
+
+ ed.ConvertInlineImageToBlock(p, img);
+
+ Assert.DoesNotContain(p.Inlines, i => i is InlineImage); // precondition: it went
+ var c = CaretOf(ed);
+ Assert.True(!ReferenceEquals(c.Paragraph, p) || c.Offset <= 2, $"caret at {c.Offset} in a 2-character paragraph");
+ });
+
+ // Unchecking "treat as character" re-creates the inline table as a block (a clone); the caret in the inline
+ // original's cell was left there, in a paragraph no longer in the document (upstream round 34).
+ [Fact]
+ public void InlineTableToBlock_LeavesTheCaretInTheDocument() => UiThread.Run(() =>
+ {
+ var inner = new TableBlock(2, 2);
+ var host = new Paragraph { Inlines = { new Run { Text = "before " }, new InlineTable { Table = inner }, new Run { Text = " after" } } };
+ var ed = Editor(P("top"), host);
+ var it = host.Inlines.OfType().Single();
+ Caret(ed, inner.Cells[0][0].Para, 0);
+
+ ed.ConvertInlineTableToBlock(host, it);
+
+ Assert.Contains(ed.Document!.Blocks, b => b is TableBlock); // precondition
+ Assert.True(InDocument(ed, CaretOf(ed).Paragraph), "the caret is in a paragraph no longer in the document");
+ });
+
+ // Picking the margin already in force left an undo step that undid nothing (upstream round 34).
+ [Fact]
+ public void AMarginAlreadyInForce_LeavesNoUndoStep() => UiThread.Run(() =>
+ {
+ var p = P("text");
+ var ed = Editor(p);
+ ed.MarkSaved();
+ Func get = () => p.MarginBottom;
+ Action set = v => p.MarginBottom = v;
+
+ typeof(Controls.RichEditor).GetMethod("PickMargin", NP)!.Invoke(ed, new object[] { get, set, p.MarginBottom });
+
+ Assert.False(ed.CanUndo);
+ });
+
+ // "Original size" on a picture already at its natural size left an undo step that undid nothing.
+ [Fact]
+ public void OriginalSize_OnAPictureAtItsNaturalSize_LeavesNoUndoStep() => UiThread.Run(() =>
+ {
+ var img = new ImageBlock { Width = 1, Height = 1 }; // the PNG is 1x1
+ img.SetImageData(Png, "image/png");
+ var ed = Editor(P("top"), img);
+ ed.MarkSaved();
+
+ typeof(Controls.RichEditor).GetMethod("ResetBlockImageNatural", NP)!.Invoke(ed, new object[] { img });
+
+ Assert.False(ed.CanUndo);
+ });
+
+ private static readonly Lazy Hosted = new(() =>
+ {
+ var ed = UiThread.Run(() => new Controls.RichEditor { Document = new FlowDocument(), PageSize = Controls.RichEditorPageSize.Continuous });
+ UiThread.Host(ed);
+ return ed;
+ }, System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
+
+ // A table held by its border stayed selected after a right-click on text elsewhere: the caret moved to the
+ // text, but the table kept its selection (and Delete then removed it). The read-only branch already dropped
+ // it ("a table shown selected earlier must not linger"); the editing branches did not (upstream round 34).
+ [Fact]
+ public void RightClickingText_DropsATableHeldByItsBorder()
+ {
+ var ed = Hosted.Value;
+ UiThread.Run(() =>
+ {
+ var tb = new TableBlock(1, 2);
+ var above = P("above the table");
+ var doc = new FlowDocument();
+ doc.Blocks.Add(above);
+ doc.Blocks.Add(tb);
+ ed.Document = doc;
+ typeof(Controls.RichEditor).GetMethod("RelayoutToViewport", NP)!.Invoke(ed, null);
+ typeof(Controls.RichEditor).GetField("_selectedBlock", NP)!.SetValue(ed, tb);
+ Assert.True(ed.HasBlockSelection); // precondition
+
+ ed.BuildContextMenuAt(new Windows.Foundation.Point(30, 8)); // on "above the table"
+
+ Assert.Same(above, CaretOf(ed).Paragraph); // precondition: the right-click landed on the text
+ Assert.False(ed.HasBlockSelection);
+ });
+ }
+}
diff --git a/tests/WinUIRichEditor.Tests/ToolbarVectorIconAndPrintTests.cs b/tests/WinUIRichEditor.Tests/ToolbarVectorIconAndPrintTests.cs
index 4b1db03..f691cb9 100644
--- a/tests/WinUIRichEditor.Tests/ToolbarVectorIconAndPrintTests.cs
+++ b/tests/WinUIRichEditor.Tests/ToolbarVectorIconAndPrintTests.cs
@@ -35,6 +35,7 @@ public class ToolbarVectorIconAndPrintTests
RichEditorIcon.IndentIncrease, RichEditorIcon.IndentDecrease, RichEditorIcon.InsertTable, RichEditorIcon.InsertImage,
RichEditorIcon.InsertDivider, RichEditorIcon.Undo, RichEditorIcon.Redo, RichEditorIcon.Highlight,
RichEditorIcon.Export, RichEditorIcon.Import, RichEditorIcon.Print, RichEditorIcon.Find, RichEditorIcon.ClearFormatting,
+ RichEditorIcon.Quote,
};
private static IEnumerable Walk(object? root)
@@ -52,6 +53,16 @@ private static IEnumerable Walk(object? root)
// ---- icons ------------------------------------------------------------------------------------
+ // The quote button beside the lists (upstream round 34 decision): without it the default UI had no way to set
+ // a quote. Found by its tooltip, as a person finds it.
+ [Fact]
+ public void TheToolbarHasAQuoteButton() => UiThread.Run(() =>
+ {
+ var tb = new RichEditorToolbar { Target = new RichEditor() };
+ Assert.Contains(Walk(tb.Content).OfType