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($"

x

", + allowLocalFileImages: true); + sw.Stop(); + + Assert.True(sw.ElapsedMilliseconds < 1000, $"the parse waited {sw.ElapsedMilliseconds} ms on the network share"); + Assert.DoesNotContain(doc.Blocks, b => b is ImageBlock); + } + + // Script links were kept and written back out into exported and clipboard HTML (upstream decision: drop on read). + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("JavaScript:alert(1)")] + [InlineData(" javascript:alert(1)")] + [InlineData("java\tscript:alert(1)")] + [InlineData("vbscript:msgbox(1)")] + [InlineData("data:text/html,<script>alert(1)</script>")] + public void AScriptLink_IsNotCarriedIntoTheDocument(string href) + { + var doc = HtmlDocumentFormatter.ParseHtml($"

click

"); + + var run = doc.Blocks.OfType().SelectMany(p => p.Inlines.OfType()).First(r => r.Text == "click"); + Assert.Null(run.NavigateUri); + } + + [Theory] + [InlineData("https://example.com/a?b=1")] + [InlineData("http://example.com")] + [InlineData("mailto:someone@example.com")] + public void AWebLink_IsKept(string href) + { + var doc = HtmlDocumentFormatter.ParseHtml($"

click

"); + + 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