From af7865fa5daa74c58621bf82781b47712fbd3afd Mon Sep 17 00:00:00 2001 From: centwon Date: Wed, 23 Sep 2026 22:54:40 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=9D=BC=EC=9A=B4=EB=93=9C34=20?= =?UTF-8?q?=E2=80=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EB=8B=BF?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8D=98=20=ED=8C=8C=EC=9D=BC=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC,=20=EA=B2=B0=ED=95=A8=2032=EA=B1=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선정 기준: 파일에 선언된 메서드 이름이 tests/에 나오지 않는 비율 + 로드맵 언급 여부. 결함마다 빨강 테스트를 먼저 만들고 수정했으며, 소스만 되돌리면 새 테스트가 빨강이 되는 것을 확인했다. 보안 - 붙여넣은 HTML의 네트워크 공유 그림(file://host/share)을 열어 SMB 연결 → NTLM 노출(21초 연결 실측). AllowLocalFileImages와 무관하게 UNC는 읽지 않는다. - javascript:/vbscript:/data: 링크를 읽을 때 버린다(글자는 남김). 내보낸 HTML·클립보드로 새어 나가던 것. 1순위 (ContextMenu·Tables·Toolbar.PageFile·Formatting·Images·FindReplace) - 우클릭이 표 테두리의 블록 캐럿을 풀지 않음 - 메뉴로 표 삭제/인라인 표→블록 후 캐럿이 문서 밖 문단에 남아 입력이 사라짐 - 인라인 그림 삭제/블록 변환 후 캐럿이 문단 끝을 넘음 - 호스트가 용지를 바꾸면 툴바가 쪽 경계를 켜고 줌을 '맞춤'으로 바꿈 - InsertDivider·ReplaceAll·ReplaceNext가 읽기 전용 문서를 고침 - 아무것도 바꾸지 않는 명령의 빈 되돌리기 단계(여백·정렬·줄 간격·내어쓰기·목록 해제·원본 크기·ReplaceAll) 2순위 (Rendering·Pagination·HitTesting·DragBlock·Clipboard) - 디코드 못 한 그림 뒤 블록이 위로 겹쳐 그려짐 - 셀 안 문단·빈 문단에 인용 막대/문단 배경 없음 - 래스터 PDF 폴백이 RGBA 픽셀에서 빨강/파랑을 바꿈 - 인라인 표가 있는 열로 ↓ 진입 실패(IsCellOf가 인라인 표를 못 봄) - AllowTables/AllowImages 꺼져도 Ctrl+끌기 복사 - 선택 위 그림/엑셀 셀 붙여넣기가 선택을 대체하지 않음 - 다른 앱용 HTML 복사에서 문단 위/아래 여백 누락 3순위 (Input·RTF·HTML) - 선택한 인라인 그림 Backspace/Delete/Ctrl+X 후 캐럿이 문단 끝을 넘음 사용자 결정 (2026-09-23) - 여백 메뉴 위쪽에 "자동(한 줄)" - Tab = 그 표의 마지막 셀에서 행 추가 - 블록 삽입 = 캐럿 자리(문단 중간이면 분할) - 툴바 인용 버튼(새 아이콘 슬롯 RichEditorIcon.Quote, Unshipped) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 65 ++++ Project_Roadmap.md | 39 ++- .../Controls/RichEditor.Clipboard.cs | 29 +- .../Controls/RichEditor.ContextMenu.cs | 18 +- .../Controls/RichEditor.DragBlock.cs | 12 +- .../Controls/RichEditor.FindReplace.cs | 9 +- .../Controls/RichEditor.Formatting.cs | 20 +- .../Controls/RichEditor.HitTesting.cs | 16 +- .../Controls/RichEditor.Images.cs | 20 +- .../Controls/RichEditor.Input.cs | 8 +- .../Controls/RichEditor.Pagination.cs | 11 +- .../Controls/RichEditor.Rendering.cs | 28 +- .../Controls/RichEditor.Tables.cs | 32 +- src/AvaloniaRichEditor/Controls/RichEditor.cs | 69 +++- .../Controls/RichEditorIcons.cs | 2 + .../Controls/RichEditorToolbar.PageFile.cs | 20 +- .../Controls/RichEditorToolbar.cs | 10 +- .../Controls/ToolbarIcons.cs | 5 + .../Formatters/HtmlDocumentFormatter.cs | 31 +- .../PublicAPI.Unshipped.txt | 1 + .../RichEditorLocalization.cs | 2 + .../RenderingAuditTests.cs | 133 +++++++ .../BlockInsertPlacementTests.cs | 96 ++++++ .../ClipboardAuditTests.cs | 94 +++++ .../ContextMenuAuditTests.cs | 324 ++++++++++++++++++ .../DragBlockAuditTests.cs | 68 ++++ .../FindReplaceAuditTests.cs | 75 ++++ .../FormattingAuditTests.cs | 59 ++++ .../HitTestingAuditTests.cs | 53 +++ .../HtmlAuditTests.cs | 59 ++++ .../ImagesAuditTests.cs | 82 +++++ .../InputAuditTests.cs | 56 +++ .../AvaloniaRichEditor.Tests/RtfAuditTests.cs | 41 +++ .../ToolbarPageFileAuditTests.cs | 65 ++++ .../ToolbarQuoteButtonTests.cs | 38 ++ 35 files changed, 1591 insertions(+), 99 deletions(-) create mode 100644 tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/DragBlockAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/FindReplaceAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/FormattingAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/HitTestingAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/ImagesAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/InputAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/RtfAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/ToolbarPageFileAuditTests.cs create mode 100644 tests/AvaloniaRichEditor.Tests/ToolbarQuoteButtonTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 767c56f..18a5479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed — audit of the files no test reached (2026-09-23) + +Round 34 read the files whose members no test named (context menu, tables, the toolbar's page controls, +formatting, images, find/replace, then rendering, pagination, hit-testing, object drag and the clipboard, +then keyboard/pointer input and the RTF and HTML formatters), +and every suspected defect was reproduced with a failing test before it was fixed. + +- **Typing after a table left through its menu went nowhere.** Deleting a table from the right-click menu, + or turning an inline table back into a block, left the caret in a cell of the removed table. +- **A right-click kept a table held by its border.** After the border was clicked, a right-click elsewhere + moved the caret but the table stayed held, and a right-click in one of its cells greyed out the cell + items. Turning a held table into an inline one left the removed table held too. +- **Deleting an inline picture, or making it a block, put the caret past the end of the paragraph** when + the caret was after the picture. +- **A host setting the paper size from code had its choice overridden by the toolbar.** Page boundaries + were switched on, and a zoom that matched no preset jumped to fit-width. +- `InsertDivider`, `ReplaceAll` and `ReplaceNext` changed a read-only document; the other editing commands + already refuse. +- Commands that changed nothing no longer leave an undo step that undoes nothing, or mark the document + modified: outdent at 0, choosing the current alignment or line spacing, removing a list from text that + has none, choosing the current margin, "Original size" on a picture already at that size, and Replace + All with no match. +- **A picture that failed to decode drew the blocks after it too high.** They overlapped the space where + the picture should be, while clicks still went to the right places. +- **Quote bars and paragraph backgrounds were missing** on paragraphs in table cells, so Quote in a cell + looked like it did nothing. They were also missing on empty lines, which broke the quote bar at every + blank line. +- **Pasting a picture or spreadsheet cells over a selection** left the selected text in place instead of + replacing it, as every other paste does. The deletion and the insert are now one undo step. +- ↓ from a table held by its border did not enter a row when the caret's column had an inline table + inside a cell. +- Ctrl+drag copied a table or picture even with `AllowTables` / `AllowImages` off. Moving an existing one + is still allowed. +- The HTML put on the clipboard for other applications dropped the top and bottom margins of paragraphs. +- The raster PDF fallback swapped red and blue where the renderer produces RGBA pixels (Skia on macOS). +- **Security: pasted HTML could make the editor connect to another machine.** An `` pointing at + a file on a network share (`file://host/share/x.png`) was opened. On Windows that starts an SMB + connection, which offers the user's NTLM credentials to that host; the attempt also froze the paste for + about 20 seconds. Network shares are now never read, whatever `AllowLocalFileImages` says. That setting + only covers files on this machine. +- **Script links (`javascript:`, `vbscript:`, `data:`) in pasted or loaded HTML are dropped.** The text + stays and the link does not. Before, the link was kept and written back out into exported HTML and the + clipboard HTML that other applications receive. The editor itself never opened such links. Variants a + browser would still accept are caught too: any letter case, leading whitespace, a tab inside the scheme, + and HTML entities. +- Backspace/Delete on a selected inline picture, and Ctrl+X on one, left the caret past the end of its + paragraph when the caret was after the picture. + +### Changed — margin menu and Tab (2026-09-23) + +- The right-click margin menu's **Top** list for a table, picture or divider starts with **Auto (one + line)**, the default since the previous change. It is checked when the block uses it. Nothing else + could restore it once a size was picked. +- **A table, picture or divider goes where the caret is.** In the middle of a paragraph the paragraph + splits and the block goes between the two halves. At the start of a paragraph the block goes before it, + and at the end after it. This applies to paste, the insert buttons and dropped files. Before, every + block went after the caret's paragraph, so a picture pasted over a word mid-line landed below the whole + paragraph. Dragging an object already worked this way. +- **The toolbar has a Quote button** beside the lists (new icon slot `RichEditorIcon.Quote`). Before, the + default UI had no way to set a quote: the right-click item appeared only with `ShowFormattingMenu`, and + there was no shortcut. +- **Tab in a table's last cell adds a row to that table**, as in Word and HWP. Only the document's last + table used to grow; from any other table, Tab jumped into the next table below. In a nested table, + Tab still moves on to the next cell of the table around it. + ### Added — page margins (2026-09-20) The margins were two constants nothing could reach: a host could pick the paper but not how much of it to diff --git a/Project_Roadmap.md b/Project_Roadmap.md index f950e25..7a0b115 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -10,12 +10,12 @@ WPF `RichTextBox`/`FlowDocument`를 **순수 C# + Avalonia `TextLayout`**로 바 --- -## ✅ 현재 상태 (2026-09-20) +## ✅ 현재 상태 (2026-09-23) - **게시**: `v1.2.1`(2026-09-08) — nuget.org. 이력: 0.9.0(07-12) → 1.0.0(07-31, API 동결) → 1.1.0(08-08) → 1.2.0/1.2.1(09-08). -- **미릴리스**: 09-09 이후 라운드(대체 텍스트 ~ 라운드33)와 **호스트 API 3건**이 전부 `CHANGELOG.md` `[Unreleased]`. +- **미릴리스**: 09-09 이후 라운드(대체 텍스트 ~ 라운드34), **호스트 API 3건**, 여백 실기 후속(PR #52)이 전부 `CHANGELOG.md` `[Unreleased]`. 공개 API **추가만**(`PublicAPI.Unshipped.txt`, 제거·시그니처 변경 없음) → 다음은 **minor(1.3.0)**. -- **검증**: 빌드 0 warn · unit **1151** + render **41** 그린 · 3-OS CI(Windows/Linux/macOS) 그린. +- **검증**: 빌드 0 warn · unit **1225** + render **51** 그린 · 3-OS CI(Windows/Linux/macOS) 그린(라운드34 전 기준). - **의존성**: Avalonia **12.1** 이상(12.0.x는 Shift+Enter 2회로 `TextLayout` 무한 루프 — 엔진 버그라 상향이 유일한 수정). **기능 (전부 동작·테스트 그린)** @@ -37,22 +37,43 @@ G2(문단 순회 워커 재귀화) · 1.0 준비 P1~P5(상호작용 테스트 ## 🎯 남은 일 **1. 1.3.0 게시** — 계획했던 호스트 API 3건이 2026-09-20에 전부 들어왔다(표 행·열 · 단축키 표 공개 · 페이지 여백, -PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이다. +PR #48·#49·#50), 실기 후속도 PR #52로 들어왔다. 남은 것은 게시 작업뿐인데, **시점은 사용자가 정한다** +(2026-09-23: "버전업은 천천히") — 먼저 제안하지 말 것. 게시 절차: `` → Unshipped를 Shipped로 이관 → CHANGELOG `[Unreleased]` → 버전 → README 배지(en/ko) → PR로 3-OS CI → `v1.3.0` 태그 push(**사람이 실행**, Trusted Publishing) → **nuget.org 패키지 페이지 육안 확인**. -**2. 실기 확인 대기** — 페이지 여백(PR #50): 여백을 바꿨을 때 머리글·바닥글·쪽번호가 띠 안에 앉는지, 인쇄·PDF의 -여백이 화면과 같은지. 헤드리스는 위치를 재기만 하고 눈으로는 못 본다. +**2. 라운드34 · 테스트가 닿지 않던 파일 감사 (2026-09-23, 진행 중, 미커밋)** — 선정 기준: 파일에 선언된 메서드 이름이 +`tests/`에 몇 번 나오는지 + 로드맵에 파일명이 나오는지. 1순위 8개(ContextMenu·Tables·Toolbar.PageFile·Formatting·Images· +FindBar·FindReplace·IncomingContent) 결함 **19건**, 2순위 5개(Rendering·Pagination·HitTesting·DragBlock·Clipboard) 결함 **10건**. +전부 빨강 먼저 → 수정, 전체 반증(소스 되돌리면 새 테스트 unit 29 + render 4 빨강). 경과는 CHANGELOG. +테스트: `*AuditTests`(unit 8개 파일) + `RenderingAuditTests`(render). +- 사용자 결정(09-23): 여백 메뉴 위쪽에 **"자동(한 줄)"** 추가 · **Tab = 그 표의 마지막 셀에서 행 추가**(중첩 표는 바깥 표 다음 셀로) · + **블록 삽입 = 캐럿 자리**(문단 중간이면 분할, 처음이면 앞, 끝이면 뒤 — `InsertBlockAtCaret`, 끌기와 같은 규칙) · + **툴바 인용 버튼**(목록 옆, 새 아이콘 슬롯 `RichEditorIcon.Quote` — Unshipped 추가). +- FindBar·IncomingContent는 결함 없음. 1순위 실기 확인 완료(09-23, 사용자). +- 남긴 것(측정 안 함·미수정): 인용 문단 두 개가 소수점 y에서 만나는 줄은 막대가 반투명하게 겹친다(원래부터, 빈 문단과 무관). +- **다음**: 3순위(Input·RTF·HTML 포매터) 또는 커밋/PR — 사용자가 정함. +- 2순위 실기 확인(09-23, 사용자): 셀 안·빈 줄 인용 막대 OK. 선택 위 그림 붙여넣기에서 "문단 아래로 들어감" → 블록 삽입 규칙 변경으로 처리. +- 블록 삽입 위치·툴바 인용 버튼 실기 확인 완료(09-23, 사용자). +- 3순위(Input·RTF·HTML) 완료: 인라인 그림 키 삭제/잘라내기 캐럿(3경로), **UNC 그림 = SMB/NTLM 노출(보안, 21초 연결 실측)**, + 스크립트 링크 읽을 때 버림(사용자 결정). RTF는 결함 없음 — 극단 값(10억 pt·음수 줄 간격)은 모델까지 들어가지만 레이아웃이 견딘다(가드 테스트). + 사용자 결정: 편집 중 링크 **일반 클릭으로 열기 유지**(포트는 Ctrl+클릭 — 알려진 분기). +- **다음**: 커밋/PR — 사용자가 정함. **3. 포트(WinUIRichEditor)로 넘길 것** (포트 쪽 반영 여부 확인 필요) +- **보안 우선**: HTML `file://host/share` 그림(UNC) 차단 — 포트는 %TEMP% 예외까지 있어 경로 검사를 먼저 볼 것. 스크립트 링크 버림. +- 라운드34 결함 29건 + 3순위 결함, 결정들(여백 "자동", Tab 행 추가, 블록 삽입 = 캐럿 자리, 툴바 인용 버튼)(여백 "자동", Tab 행 추가) — 포트에 같은 결함이 있는지 먼저 측정 - 그림 손잡이 vs 열 경계 우선순위: **선택된 그림 손잡이 우선**(여기 방식)으로 포트를 맞춘다 (사용자 결정 2026-09-19) - 테스트 `CtrlU_AtALinksEnd_LeavesTheLinkAlone`(라운드19에서 여기만 추가) +- PR #52의 실기 후속 전부: 여백 mm 단위·5단계 여백 픽커, 얇은 여백 띠의 머리글/바닥글 생략, 표 바깥 테두리 반 펜 안쪽(쪽 경계), + 표·그림·구분선 위 자동 한 줄 간격(`Block.AutoTopMargin`=NaN), 그림 외곽선을 반 펜 바깥으로 + 쪽 클립 뒤에 그리기 **알려진 제한 (의도적으로 둠)** - RTF 중첩 표의 열 너비는 가져올 때 기본값(`\cellx`가 무시되는 props 그룹 안에 있다). - RTF는 비대칭: 인라인 표·대체 텍스트는 RTF 자리가 없음(인라인 표는 `{\*\arinline}`로 자체 왕복만 복원). -- 붙여넣기 세 경로(앱 내부·RTF·HTML)의 **배선**은 헤드리스에 실제 클립보드가 없어 테스트 밖(적응 로직은 `InsertHtml`로 검증). +- ~~붙여넣기 배선은 헤드리스에 클립보드가 없어 테스트 밖~~ — **틀린 기록이었다**(라운드34): 헤드리스 `TopLevel.Clipboard`가 + `SetTextAsync`/`SetDataAsync`로 왕복하고 `PasteFromClipboardAsync`를 끝까지 태운다(`ClipboardAuditTests`). 세 경로 배선 테스트는 이제 쓸 수 있다. - mac/Linux는 best-effort(3-OS CI 그린, 실기 동작은 리포트 주도). --- @@ -67,6 +88,8 @@ PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이 | 표를 통째로 잡은 상태(전체 선택·블록 캐럿)의 Delete/잘라내기 = **표 삭제**. 일부 셀 블록은 셀만 비움 | 09-13 | | 셀 블록 = 선택 두 끝에서 파생(모드 플래그 없음). 한 셀 블록은 `_cellBlockMark`. 포트와 통일 | 09-13 | | 제목 적용 시 run 크기: **여기는 본문 기본값으로 초기화**, 포트는 직접 지정 크기 유지 — 알려진 분기 | 09-12 | +| 편집 중 링크 클릭: **여기는 일반 클릭으로 연다**, 포트는 Ctrl+클릭 — 알려진 분기(사용자 결정, 유지) | 09-23 | +| 붙여넣은/읽은 HTML의 스크립트 링크(`javascript:`·`vbscript:`·`data:`)는 읽을 때 버린다(글자는 남김). 네트워크 공유 `file://` 그림은 설정과 무관하게 안 읽는다 | 09-23 | | WinUI의 "게이트 추출"(순수 함수화)은 백포트하지 않는다 — 여기는 `InteractionHost`로 실입력 구동이 이미 가능 | 08-06 | | CI 퍼즈 시드는 20(병합 결함을 8/20이 잡음), 큰 시드(5000)는 손으로 | 09-07 | | 벡터 PDF는 Skia PDF(여러 OS). 포트는 별도로 Windows "Print to PDF" 경로 | 09-12 | @@ -80,6 +103,8 @@ PR #48·#49·#50). 경계 결정은 끝났고 남은 것은 게시 작업뿐이 - **포매터는 전부 붙여넣기로 닿는 신뢰 불가 입력이다.** 정상 왕복 말고 깨진·악성 입력을 넣을 것. 누적형 결함은 **반복 왕복(고정점)**으로만 보인다. - **생성 쪽이 만들지 않는 형태는 조용히 0% 커버다.** 불변식을 늘리는 것과 그 불변식이 실행되게 하는 것은 다른 일(퍼즈에 병합이 없었다). - **반증(수정을 되돌려 테스트가 빨개지는지)을 매번 한다.** 폴백이 "틀린 이유로 맞는 답"을 주는 공허한 테스트가 여러 번 잡혔다. + 라운드34에서도 셋: 캐럿 없는 편집기(문단 명령이 조기 반환), 착지점이 인라인 표를 빗나간 ↓, 투명 배경을 흰색으로 가정한 픽셀 판정. + **"그 경우를 실제로 탔다"는 전제 단정**(예: 캐럿이 인라인 표 안에 떨어졌는지)을 테스트에 넣어 둘 것. - **새 축을 열 땐 하네스부터 의심하라.** 안 끝나는/죽는 테스트는 재실행 말고 **프로세스를 소유해서 계측**(콘솔 앱 + `dotnet-stack`). - `InvalidateMeasure` 누락 주장 전 체인 확인: `ResetCaretBlink()`→`NotifyStatus()`→`InvalidateMeasure()`, `PushUndo()`는 레이아웃 캐시를 버린다 (외부 감사 8건 오진의 원인). diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs index 1a8c7f7..6af9096 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Clipboard.cs @@ -105,16 +105,17 @@ public async Task PasteFromClipboardAsync() var (clipImage, clipBytes, clipMeta) = AllowImages ? await TryGetImageAsync(clipboard) : ((Avalonia.Media.Imaging.Bitmap?)null, (byte[]?)null, (string?)null); + // Like every other paste, 4 and 5 REPLACE a selection — in the same undo step as the insert. They went in + // beside it and the selected text stayed (round 34). if (clipImage != null) { var meta = ParseImageMeta(clipMeta); + PushUndo(); + if (_selectionStart != _selectionEnd) DeleteSelection(); if (meta is { Inline: true } im && clipBytes != null) - { - PushUndo(); InsertInlineImageAtCaret(clipBytes, im.W, im.H); - } - else if (clipBytes != null) InsertImageBytes(clipBytes, meta?.W ?? 0, meta?.H ?? 0); // keep the original encoding - else InsertImage(Downscale(clipImage)); // raw Bitmap object: no bytes to keep + else if (clipBytes != null) InsertImageBytes(clipBytes, meta?.W ?? 0, meta?.H ?? 0, pushUndo: false); // keep the original encoding + else InsertBitmapBlock(Downscale(clipImage), pushUndo: false); // raw Bitmap object: no bytes to keep ResetCaretBlink(); // image lands just after the caret block — scroll there return; } @@ -123,6 +124,7 @@ public async Task PasteFromClipboardAsync() if (AllowTables && !string.IsNullOrEmpty(text) && LooksTabular(text)) { PushUndo(); + if (_selectionStart != _selectionEnd) DeleteSelection(); InsertTableFromTsv(text); ResetCaretBlink(); // table lands just after the caret block — scroll there return; @@ -221,8 +223,9 @@ private void OnDrop(object? sender, DragEventArgs e) public void InsertImageBytes(byte[] bytes) => InsertImageBytes(bytes, 0, 0); // Core insert with an optional display size (used by paste to restore the copied image's size; - // 0 = natural size). - private void InsertImageBytes(byte[] bytes, double displayW, double displayH) + // 0 = natural size). `pushUndo` false: the caller has taken the checkpoint (paste, which deletes the + // selection in the same step). + private void InsertImageBytes(byte[] bytes, double displayW, double displayH, bool pushUndo = true) { if (Document == null || IsReadOnly || !AllowImages) return; Avalonia.Media.Imaging.Bitmap bmp; @@ -249,7 +252,7 @@ private void InsertImageBytes(byte[] bytes, double displayW, double displayH) ib.SetImageData(bytes, ImageMime.Detect(bytes)); } - PushUndo(); + if (pushUndo) PushUndo(); InsertBlockAtCaret(ib); InvalidateVisual(); } @@ -349,12 +352,10 @@ private static async Task SetClipboardTextAndHtmlAsync(IClipboard clipboard, str // whose single position falls inside the range) of one paragraph. private static Paragraph CloneParagraphRange(Paragraph p, int from, int to) { - var np = new Paragraph - { - ListType = p.ListType, ListMarker = p.ListMarker, ListLevel = p.ListLevel, HeadingLevel = p.HeadingLevel, - TextAlignment = p.TextAlignment, Indent = p.Indent, MarginRight = p.MarginRight, - IsQuote = p.IsQuote, Background = p.Background, LineHeight = p.LineHeight, LineSpacing = p.LineSpacing - }; + // The one list of paragraph fields: a hand-picked copy here missed the top/bottom margins, which the HTML + // writer emits — a spaced paragraph pasted into Word lost its spacing. + var np = new Paragraph(); + np.CopyFormatFrom(p); int idx = 0; foreach (var inl in p.Inlines) { diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs b/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs index e44731c..99a9f01 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.ContextMenu.cs @@ -128,19 +128,22 @@ void Highlight(int hr, int hc) // around images/tables, so a right margin would be invisible there. private MenuItem MarginMenu(Block target) { - Control[] Presets(Func get, Action set) + // `auto`: lead with Block.AutoTopMargin (NaN, one line gap) — the default top of a table, picture or divider, + // which no px preset shows checked and nothing else could restore once a preset was picked. + Control[] Presets(Func get, Action set, bool auto = false) { var items = new List(); - foreach (double v in new[] { 0d, 5, 10, 20, 30 }) + foreach (double v in auto ? new[] { Block.AutoTopMargin, 0d, 5, 10, 20, 30 } : new[] { 0d, 5, 10, 20, 30 }) { var mi = new MenuItem { - Header = $"{v:0} px", + Header = double.IsNaN(v) ? Loc("MarginAuto") : $"{v:0} px", ToggleType = MenuItemToggleType.Radio, - IsChecked = Math.Abs(get() - v) < 0.5, + IsChecked = double.IsNaN(v) ? double.IsNaN(get()) : Math.Abs(get() - v) < 0.5, }; mi.Click += (_, _) => { + if (get().Equals(v)) return; // already in force (NaN included): no undo step that undoes nothing (as the cell v-align radio) if (Document != null) PushUndo(); set(v); NotifyStatus(); // content size changed -> re-measure scroll extent @@ -152,7 +155,7 @@ Control[] Presets(Func get, Action set) } var sides = new List { - Sub(Loc("MarginTop"), Presets(() => target.MarginTop, v => target.MarginTop = v)), + Sub(Loc("MarginTop"), Presets(() => target.MarginTop, v => target.MarginTop = v, auto: target is not Paragraph)), Sub(Loc("MarginBottom"), Presets(() => target.MarginBottom, v => target.MarginBottom = v)), Sub(Loc("MarginLeft"), Presets(() => target.Indent, v => target.Indent = v)), }; @@ -190,6 +193,9 @@ private void CollapseSelectionToCaret() private void ShowContextMenu(Point point) { if (Document == null) return; + // A left press clears the block caret first thing (OnPointerPressed); the right-click leaves before that + // line, so it is cleared here. Only the border branch below holds a table again. + _caretBlock = null; bool hasSelection = HasTextOrCellSelection; @@ -464,7 +470,7 @@ private MenuItem HeadingRadio(string header, int level, int current) /// Inserts a horizontal rule () at the caret position. public void InsertDivider() { - if (Document == null) return; + if (Document == null || IsReadOnly) return; PushUndo(); InsertBlockAtCaret(new DividerBlock()); InvalidateVisual(); diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs b/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs index 00a256a..dbaca44 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.DragBlock.cs @@ -59,7 +59,7 @@ internal void DragObjectMoved(Point docPt, bool copy) _trustLayoutCache = true; // hit-testing only — never mutates, as on the drag-select path try { tp = GetPositionFromPoint(docPt); } finally { _trustLayoutCache = false; } - _dropPreview = CanDropObject(_dragObject, tp, copy) ? tp : null; + _dropPreview = CanDropObject(_dragObject, tp, copy) && (!copy || MayCreate(_dragObject)) ? tp : null; Cursor = _dropPreview != null ? ArrowCursor : NoDropCursor; InvalidateVisual(); } @@ -119,6 +119,7 @@ internal static bool CanDropObject(object obj, TextPointer at, bool copy) 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), @@ -127,6 +128,15 @@ internal bool DropObject(object obj, TextPointer at, bool copy) }; } + // 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, as the row/column commands edit one. + private bool MayCreate(object obj) => obj switch + { + TableBlock or InlineTable => AllowTables, + ImageBlock or InlineImage => AllowImages, + _ => true, + }; + private static IList? BlockListOf(TextElement e) => e.Parent switch { FlowDocument d => d.Blocks, diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs b/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs index 5b36577..4470a13 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.FindReplace.cs @@ -124,7 +124,8 @@ public bool ReplaceNext(string query, string replacement, bool matchCase) { if (!AllowFindReplace || Document == null || string.IsNullOrEmpty(query)) return false; var cmp = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - bool selMatches = _selectionStart.Paragraph != null && _selectionStart.CompareTo(_selectionEnd) != 0 + // Read-only: only the find half (the bar checks this too, but a host's own find UI calls in directly). + bool selMatches = !IsReadOnly && _selectionStart.Paragraph != null && _selectionStart.CompareTo(_selectionEnd) != 0 && string.Equals(new TextRange(_selectionStart, _selectionEnd).GetText(), query, cmp); if (selMatches) { @@ -139,9 +140,11 @@ public bool ReplaceNext(string query, string replacement, bool matchCase) /// Returns the number of replacements made. public int ReplaceAll(string query, string replacement, bool matchCase) { - if (!AllowFindReplace || Document == null || string.IsNullOrEmpty(query)) return 0; + if (!AllowFindReplace || Document == null || IsReadOnly || string.IsNullOrEmpty(query)) return 0; var paras = GetAllParagraphsInOrder(); - 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(); _caretPosition = new TextPointer(paras[0], 0); CollapseSelectionToCaret(); diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs index 37f8cd4..12c0279 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Formatting.cs @@ -179,12 +179,15 @@ private void StepFontSize(int dir) // directly: selecting several paragraphs and clicking "center" only aligned the one the caret // happened to land on, while the list commands on the same toolbar already applied to the whole // selection. NotifyStatus because indent/spacing/heading all change block heights. - private void ApplyToSelectedParagraphs(Action action) + // `unchanged`: true when the action would leave that paragraph as it is. When it holds for every target, nothing + // is recorded — outdenting at 0 or re-picking the current alignment left an undo step that undid nothing. + private void ApplyToSelectedParagraphs(Action action, Func? unchanged = null) { if (_caretPosition.Paragraph == null || IsReadOnly) return; - if (Document != null) PushUndo(); var targets = SelectedParagraphsInOrder(); if (targets.Count == 0) targets = new List { _caretPosition.Paragraph }; + if (unchanged != null && targets.All(unchanged)) return; + if (Document != null) PushUndo(); foreach (var p in targets) action(p); InvalidateVisual(); NotifyStatus(); @@ -193,20 +196,21 @@ private void ApplyToSelectedParagraphs(Action action) /// Adjusts the indent of every selected paragraph by pixels /// (each clamped 0–400); the caret paragraph alone when nothing is selected. public void Indent(double delta) - => ApplyToSelectedParagraphs(p => p.Indent = Math.Clamp(p.Indent + delta, 0, 400)); + => ApplyToSelectedParagraphs(p => p.Indent = Math.Clamp(p.Indent + delta, 0, 400), + p => Math.Clamp(p.Indent + delta, 0, 400) == p.Indent); /// Sets the text alignment of every selected paragraph (the caret paragraph when nothing /// is selected). public void SetTextAlignment(TextAlignment align) - => ApplyToSelectedParagraphs(p => p.TextAlignment = align); + => ApplyToSelectedParagraphs(p => p.TextAlignment = align, p => p.TextAlignment == align); /// Sets the absolute line-box height (px) of every selected paragraph ("exactly" spacing). /// Prefer for proportional spacing that scales with font size. public void SetLineHeight(double height) - => ApplyToSelectedParagraphs(p => p.LineHeight = height); + => ApplyToSelectedParagraphs(p => p.LineHeight = height, p => p.LineHeight.Equals(height)); // Equals: NaN matches NaN /// Sets proportional line spacing on every selected paragraph as HWP % ÷ 100 — line box = /// largest font size × (1.6 = 160%). clears it /// (back to the HWP default 160%). public void SetLineSpacing(double multiplier) - => ApplyToSelectedParagraphs(p => p.LineSpacing = multiplier); + => ApplyToSelectedParagraphs(p => p.LineSpacing = multiplier, p => p.LineSpacing.Equals(multiplier)); /// Toggles a bullet list on the selected paragraphs. public void ToggleBullet() { SetListType(ListKind.Bullet); } /// Toggles a numbered list on the selected paragraphs. @@ -221,10 +225,12 @@ public void SetLineSpacing(double multiplier) public void RemoveList() { if (_caretPosition.Paragraph == null || Document == null || IsReadOnly) return; - PushUndo(); // Any depth: clearing a list needs no block splicing, so cell paragraphs are cleared too. var targets = SelectedParagraphsInOrder(); if (targets.Count == 0) targets = new List { _caretPosition.Paragraph }; + // No list anywhere in it: no undo step that undoes nothing (see ApplyToSelectedParagraphs). + if (targets.All(p => p.ListType == ListKind.None && p.ListMarker == ListMarkerStyle.Default && p.ListLevel == 0)) return; + PushUndo(); foreach (var p in targets) { p.ListType = ListKind.None; diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs index 9caf589..9ee0eb9 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.HitTesting.cs @@ -80,18 +80,10 @@ public partial class RichEditor return null; } - // True when paragraph p lives anywhere inside table tb, including inside a nested table (P4-2b). - private static bool IsCellOf(TableBlock tb, Paragraph p) - { - for (int r = 0; r < tb.Rows; r++) - for (int c = 0; c < tb.Columns; c++) - foreach (var b in tb.Cells[r][c].Blocks) - { - if (ReferenceEquals(b, p)) return true; - if (b is TableBlock nt && IsCellOf(nt, p)) return true; - } - return false; - } + // True when paragraph p lives anywhere inside table tb, at any depth — nested tables and inline tables in a + // cell's paragraph included. Through the parent chain: the cell walk it replaced skipped inline tables, so + // ↓ from a table's block caret into a column holding one was judged "not entered" (round 34). + private static bool IsCellOf(TableBlock tb, Paragraph p) => IsWithin(p, tb); // Pixel geometry of one table. Anchor cells get a rect spanning their merged columns/rows; // covered cells are absorbed into their anchor and never appear here. diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs index 14ae742..9782642 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Images.cs @@ -104,6 +104,7 @@ private static void DrawPicture(Avalonia.Media.DrawingContext context, Avalonia. private void ResetImageSize(ImageBlock img) { if (Document == null || NaturalSize(img) is not { } natural) return; + if (img.Width == natural.Width && img.Height == natural.Height) return; // already: no undo step that undoes nothing PushUndo(); img.Width = natural.Width; img.Height = natural.Height; @@ -260,7 +261,7 @@ internal void ConvertInlineImageToBlock(Paragraph p, InlineImage im) var ib = new ImageBlock { Width = im.Width, Height = im.Height }; if (im.RawBytes != null) ib.SetImageData(im.RawBytes, im.MimeType, im.CachedBitmap); else ib.Image = im.Image; - p.Inlines.Remove(im); + RemoveInlineImageCharacter(p, im); Document.Blocks.Insert(idx + 1, ib); UpdateParents(Document); _selectedInline = null; @@ -273,14 +274,29 @@ private void DeleteInlineImage(Paragraph p, InlineImage img) { if (Document == null) return; PushUndo(); - p.Inlines.Remove(img); + RemoveInlineImageCharacter(p, img); if (_selectedInline is { } s && ReferenceEquals(s.img, img)) _selectedInline = null; + InvalidateMeasure(); // the line box loses the picture's height InvalidateVisual(); } + // Takes a picture's one character out of its paragraph, pulling back the caret and selection ends that were + // past it. They kept their offsets, so the caret after a trailing picture sat one past the paragraph's end and + // the next keys went nowhere. + private void RemoveInlineImageCharacter(Paragraph p, InlineImage img) + { + int off = OffsetOfInline(p, img); + p.Inlines.Remove(img); + TextPointer Back(TextPointer t) => ReferenceEquals(t.Paragraph, p) && t.Offset > off ? new TextPointer(p, t.Offset - 1) : t; + _caretPosition = Back(_caretPosition); + _selectionStart = Back(_selectionStart); + _selectionEnd = Back(_selectionEnd); + } + private void ResetInlineImageSize(InlineImage img) { if (Document == null || NaturalSize(img) is not { } natural) return; + if (img.Width == natural.Width && img.Height == natural.Height) return; // see ResetImageSize PushUndo(); img.Width = natural.Width; img.Height = natural.Height; diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs index dfa13e0..655103b 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Input.cs @@ -1070,8 +1070,9 @@ or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin { _ = CopyImageToClipboardAsync(xi.img.RawBytes, xi.img.RawBytes == null ? xi.img.Image : null, inline: true, xi.img.Width, xi.img.Height); PushUndo(); - xi.p.Inlines.Remove(xi.img); + RemoveInlineImageCharacter(xi.p, xi.img); // pulls back a caret that sat after it _selectedInline = null; + InvalidateMeasure(); ResetCaretBlink(); InvalidateVisual(); e.Handled = true; return; } } @@ -1295,9 +1296,10 @@ or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin } else if ((e.Key == Key.Back || e.Key == Key.Delete) && _selectedInline is { } selInl && Document != null) { - // A selected inline image deletes as a unit (state was pushed above). - selInl.p.Inlines.Remove(selInl.img); + // A selected inline image deletes as a unit (state was pushed above), pulling back a caret after it. + RemoveInlineImageCharacter(selInl.p, selInl.img); _selectedInline = null; + InvalidateMeasure(); ResetCaretBlink(); e.Handled = true; return; } diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs index cfb11da..cc9086f 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Pagination.cs @@ -444,10 +444,12 @@ internal void DrawVectorPage(SkiaSharp.SKCanvas canvas, int pageIndex, IReadOnly render.GetAwaiter().GetResult(); } - // BGRA8888 (RenderTargetBitmap's format) -> packed top-down RGB24. Alpha is always 255 here: - // print pages start from an opaque white fill. + // BGRA8888 or RGBA8888 -> packed top-down RGB24. Alpha is always 255 here: print pages start from an + // opaque white fill. The order is the bitmap's own: Skia renders RGBA on macOS, and reading that as BGRA + // swapped red and blue in every raster page. private static (int width, int height, byte[] rgb) BitmapToRgb24(Avalonia.Media.Imaging.Bitmap bmp) { + bool rgbaOrder = bmp.Format == Avalonia.Platform.PixelFormat.Rgba8888; var ps = bmp.PixelSize; int stride = ps.Width * 4; var bgra = new byte[stride * ps.Height]; @@ -459,11 +461,12 @@ private static (int width, int height, byte[] rgb) BitmapToRgb24(Avalonia.Media. finally { handle.Free(); } var rgb = new byte[ps.Width * ps.Height * 3]; + int r = rgbaOrder ? 0 : 2, b = rgbaOrder ? 2 : 0; for (int i = 0, j = 0; i < bgra.Length; i += 4, j += 3) { - rgb[j] = bgra[i + 2]; + rgb[j] = bgra[i + r]; rgb[j + 1] = bgra[i + 1]; - rgb[j + 2] = bgra[i]; + rgb[j + 2] = bgra[i + b]; } return (ps.Width, ps.Height, rgb); } diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs index 36846ec..acec113 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs @@ -458,6 +458,9 @@ private static Rect InsetTableEdges(Rect cell, Rect table) if (fullText == "" && !hasPreedit) { + // An empty line inside a quote (or a shaded run of paragraphs) keeps the bar and the fill — + // returning before them broke the quote bar at every blank line. + DrawParagraphDecor(context, paragraph, px, yOffset, ParagraphWrapWidth(paragraph, maxWidth), beHeight, 0); if (paragraph.ListType != ListKind.None) DrawListMarker(context, paragraph, paragraph.ListType == ListKind.Ordered ? ++orderedIndex : 0, px, yOffset); if (chrome && _caretPosition != null && _caretPosition.Paragraph == paragraph) @@ -509,11 +512,7 @@ private static Rect InsetTableEdges(Rect cell, Rect table) continue; } - if (paragraph.Background != null) - context.FillRectangle(paragraph.Background, new Rect(px, yOffset, pWidth, layout.Height)); - - if (paragraph.IsQuote) - context.FillRectangle(Brushes.Silver, new Rect(Math.Max(0, px - 10), yOffset, 3, layout.Height)); + DrawParagraphDecor(context, paragraph, px, yOffset, pWidth, layout.Height, 0); if (selectedParagraphs?.Contains(paragraph) == true) { @@ -598,9 +597,10 @@ private static Rect InsetTableEdges(Rect cell, Rect table) _imageHandles.Add((grab, img, width, height, grip)); } } - - yOffset += height + img.MarginBottom; } + // Outside the draw: a picture that does not decode still takes its height, as measure and the + // hit-tests give it — skipping the advance drew everything after it that much too high. + yOffset += height + img.MarginBottom; } else if (block is DividerBlock dv) { @@ -651,6 +651,16 @@ private Rect CaretRectIn(Avalonia.Media.TextFormatting.TextLayout layout, Paragr return new Rect(prev.X + prev.Width, prev.Y, 0, prev.Height); } + // A paragraph's background fill and quote bar, shared by the top-level and cell walks. The bar sits 10px left + // of the text, never left of `minBarX`. + private static void DrawParagraphDecor(DrawingContext context, Paragraph p, double px, double y, double width, double height, double minBarX) + { + if (p.Background != null) + context.FillRectangle(p.Background, new Rect(px, y, width, height)); + if (p.IsQuote) + context.FillRectangle(Brushes.Silver, new Rect(Math.Max(minBarX, px - 10), y, 3, height)); + } + private void DrawCellBlockList( DrawingContext context, System.Collections.Generic.IList blocks, double ox, double oy, double innerW, bool chrome, @@ -690,6 +700,10 @@ private void DrawCellBlockList( } } + // A cell paragraph's fill and quote bar: the cell walk drew neither, so 인용 in a cell changed + // nothing on screen. The bar stays inside the cell's padding (`ox - 4`), clear of its border. + DrawParagraphDecor(context, para, px, blkY, pw, layout.Height, ox - 4); + if (!cellSelected && !cellRangeActive && selectedParagraphs?.Contains(para) == true) { int cellLen = GetParagraphLength(para); diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs index 696c176..c7c2a69 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.Tables.cs @@ -176,13 +176,7 @@ private bool RemoveTableHeldWhole() int idx = container.IndexOf(tb); RemoveBlockAnywhere(tb); UpdateParents(Document); // a paragraph now borders the gap (top level); a cell keeps one - Paragraph? landing = null; - for (int i = Math.Max(0, idx); i < container.Count && landing == null; i++) - if (container[i] is Paragraph p) landing = p; - for (int i = Math.Min(idx, container.Count) - 1; i >= 0 && landing == null; i--) - if (container[i] is Paragraph p) landing = p; - landing ??= GetAllParagraphsInOrder().FirstOrDefault(); - _caretPosition = new TextPointer(landing, 0); + PlaceCaretAtGap(container, idx); } CollapseSelectionToCaret(); InvalidateMeasure(); @@ -489,20 +483,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 TOP-LEVEL table (nested tables don't grow - // via Tab — use the right-click menu), walking up the parent chain if the last 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 (user decision, 2026-09-23). if (Document != null) PushUndo(); top.InsertRow(top.Rows); if (Document != null) UpdateParents(Document); @@ -616,6 +613,7 @@ internal void ConvertTableBlockToInline(TableBlock tb) if (atEnd) anchor.Inlines.Add(it); else anchor.Inlines.Insert(0, it); if (ReferenceEquals(_selectedBlock, tb)) _selectedBlock = null; + if (ReferenceEquals(_caretBlock, tb)) _caretBlock = null; // held by its border: the block caret named the removed table UpdateParents(Document); int off = 0; @@ -638,9 +636,19 @@ internal void ConvertInlineTableToBlock(Paragraph host, InlineTable it) PushUndo(); var tb = (TableBlock)it.Table.Clone(); + // The caret (and a whole-table selection) may be in the inline original's cells, which leave the document + // with it — typing went nowhere. It goes where the table was, as DeleteInlineTable puts it. + bool caretInside = IsWithin(_caretPosition.Paragraph, it.Table) + || IsWithin(_selectionStart.Paragraph, it.Table) || IsWithin(_selectionEnd.Paragraph, it.Table); + int off = OffsetOfInline(host, it); host.Inlines.Remove(it); Document.Blocks.Insert(idx + 1, tb); UpdateParents(Document); + if (caretInside) + { + _caretPosition = new TextPointer(host, off); + CollapseSelectionToCaret(); + } _selectedBlock = tb; ResetCaretBlink(); InvalidateMeasure(); diff --git a/src/AvaloniaRichEditor/Controls/RichEditor.cs b/src/AvaloniaRichEditor/Controls/RichEditor.cs index 251a7d6..6eafade 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditor.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditor.cs @@ -1846,17 +1846,34 @@ private void InsertBlocksAfterCaretBlock(System.Collections.Generic.IReadOnlyLis return (Document!.Blocks, ti >= 0 ? ti + 1 : Document.Blocks.Count); } - // Inserts a top-level block immediately after the caret's current block (or at the end - // when the caret isn't in a normal paragraph), instead of always appending to the document. + // Inserts a block where the caret is, in the caret paragraph's own block list (the document, or a cell's): + // in the middle of the paragraph it splits and the block goes between the halves; at its start the block goes + // before it; at its end (or in an empty one) after it — the rule the object drag has (DropBlock). Every + // insert used to go AFTER the paragraph, so a picture pasted over a word mid-line landed below the whole + // paragraph (user decision, 2026-09-23). private void InsertBlockAtCaret(Block b) { if (Document == null) return; - // A block image / divider / nested table (P4-2b) inserts inside the current cell, after the - // caret's paragraph, when the caret is in a cell. + bool before = false; + if (_caretPosition.Paragraph is { Parent: FlowDocument or TableCell } cp) + { + int len = GetParagraphLength(cp); + int off = Math.Clamp(_caretPosition.Offset, 0, len); + if (off > 0 && off < len) + { + int heading = cp.HeadingLevel; + SplitParagraphAtCaret(); + // The tail continues cp: Enter's "a heading's next line is body text" is a typing rule (as DropBlock). + _caretPosition.Paragraph!.HeadingLevel = heading; + _caretPosition = new TextPointer(cp, GetParagraphLength(cp)); // the block goes after the head + } + else if (off == 0 && len > 0) before = true; + } + // A block image / divider / nested table (P4-2b) inserts inside the current cell when the caret is in one. if (_caretPosition.Paragraph?.Parent is TableCell tc) { int pi = tc.Blocks.IndexOf(_caretPosition.Paragraph); - int at = pi >= 0 ? pi + 1 : tc.Blocks.Count; + int at = pi < 0 ? tc.Blocks.Count : before ? pi : pi + 1; tc.Blocks.Insert(at, b); // Guarantee a paragraph after the block so the caret has somewhere to land and type. if (at + 1 >= tc.Blocks.Count || tc.Blocks[at + 1] is not Paragraph) @@ -1878,7 +1895,7 @@ private void InsertBlockAtCaret(Block b) if (caretBlock != null) { int i = Document.Blocks.IndexOf(caretBlock); - if (i >= 0) insertIndex = i + 1; + if (i >= 0) insertIndex = before && ReferenceEquals(caretBlock, _caretPosition.Paragraph) ? i : i + 1; } Document.Blocks.Insert(insertIndex, b); UpdateParents(Document); // NormalizeBlocks guarantees a paragraph exists after b @@ -1899,10 +1916,13 @@ private void InsertBlockAtCaret(Block b) /// Inserts a block image from a at the caret. /// When the encoded bytes are available, prefer to avoid re-encoding. - public void InsertImage(Avalonia.Media.Imaging.Bitmap image) + public void InsertImage(Avalonia.Media.Imaging.Bitmap image) => InsertBitmapBlock(image, pushUndo: true); + + // `pushUndo` false: the caller took the checkpoint (see InsertImageBytes). + private void InsertBitmapBlock(Avalonia.Media.Imaging.Bitmap image, bool pushUndo) { if (Document == null || IsReadOnly || !AllowImages) return; - PushUndo(); + if (pushUndo) PushUndo(); var (w, h) = CapToContentWidth(image.Size.Width, image.Size.Height); var ib = new ImageBlock { Image = image, Width = w, Height = h }; InsertBlockAtCaret(ib); @@ -2315,13 +2335,46 @@ private void DeleteBlock(Block b) { if (Document == null) return; PushUndo(); + IList container = b.Parent is TableCell cell ? cell.Blocks : Document.Blocks; + int idx = container.IndexOf(b); + // A table deleted from its menu usually holds the caret (the right-click put it in a cell) or the selection + // (its border held it whole). Left there, the keys typed next went into a paragraph no longer in the document. + bool caretInside = IsWithin(_caretPosition.Paragraph, b) + || IsWithin(_selectionStart.Paragraph, b) || IsWithin(_selectionEnd.Paragraph, b); RemoveBlockAnywhere(b); _selectedBlock = null; + if (ReferenceEquals(_caretBlock, b)) _caretBlock = null; UpdateParents(Document); // NormalizeBlocks (top level) + re-wire; a cell keeps its paragraph invariant + if (caretInside) + { + PlaceCaretAtGap(container, idx); + CollapseSelectionToCaret(); + } InvalidateMeasure(); // a cell shrank -> the table's row height reflows InvalidateVisual(); } + // Whether `e` is `ancestor` or lies inside it (a cell paragraph of a table, at any depth). + private static bool IsWithin(TextElement? e, TextElement ancestor) + { + for (object? cur = e; cur != null; cur = (cur as TextElement)?.Parent) + if (ReferenceEquals(cur, ancestor)) return true; + return false; + } + + // The caret after a block left `container` at `idx`: the paragraph now in its place, else the nearest one + // before it, else the document's first. + private void PlaceCaretAtGap(IList container, int idx) + { + Paragraph? landing = null; + for (int i = Math.Max(0, idx); i < container.Count && landing == null; i++) + if (container[i] is Paragraph p) landing = p; + for (int i = Math.Min(idx, container.Count) - 1; i >= 0 && landing == null; i--) + if (container[i] is Paragraph p) landing = p; + landing ??= GetAllParagraphsInOrder().FirstOrDefault(); + _caretPosition = new TextPointer(landing, 0); + } + // Removes a block from whichever container holds it: the document's top-level list or an enclosing // table cell (searching recursively through nested tables — P4-2b). Returns true if removed. private bool RemoveBlockAnywhere(Block b) diff --git a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs index c7cd500..87d8676 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorIcons.cs @@ -117,6 +117,8 @@ public enum RichEditorIcon Find, /// Page margins (the toolbar's margin picker). PageMargin, + /// Quote / blockquote toggle (the toolbar's quote button). + Quote, } /// diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs index 78b4f4a..47372c2 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.PageFile.cs @@ -235,12 +235,7 @@ private static string MarginText(PageMargins m) /// Reflects the host's current zoom / fit-width and the editor's paper state onto the built-in /// page controls. Call after changing view-level zoom (which the toolbar can't observe directly). - public void RefreshPageControls() - { - _suppress = true; - try { SyncPage(); } - finally { _suppress = false; } - } + public void RefreshPageControls() => SyncPage(); private void OnPaperChanged() { @@ -254,8 +249,19 @@ private void OnPaperChanged() SyncPage(); } - // Reflect the editor's page/zoom state onto the built-in controls (called from Sync()). + // Reflect the editor's page/zoom state onto the built-in controls (called from Sync()). Guarded here, not by + // each caller: it is also called straight from the target's PageSize change and after a paper pick, and there + // setting the combos fired their handlers — a host's paper was answered as a picked one (boundaries forced on), + // and emptying the zoom combo for an off-grid zoom read as "Fit". private void SyncPage() + { + bool was = _suppress; + _suppress = true; + try { SyncPageCore(); } + finally { _suppress = was; } + } + + private void SyncPageCore() { if (Target == null) return; bool paged = Target.PageSize != RichEditorPageSize.Continuous; diff --git a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs index ab06805..75534da 100644 --- a/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs +++ b/src/AvaloniaRichEditor/Controls/RichEditorToolbar.cs @@ -57,7 +57,7 @@ public RichEditor? Target private static readonly IBrush ActiveBrush = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Color.Parse("#90CAF9")); // Controls that reflect caret state (assigned in Build). - private Button? _boldBtn, _italicBtn, _underlineBtn, _strikeBtn, _bulletBtn, _numberBtn, _undoBtn, _redoBtn; + private Button? _boldBtn, _italicBtn, _underlineBtn, _strikeBtn, _bulletBtn, _numberBtn, _quoteBtn, _undoBtn, _redoBtn; private ComboBox? _fontCombo, _sizeCombo, _headingCombo, _alignCombo; private TextBox? _spacingBox; // editable line-spacing %, reflects/sets the caret paragraph private TextBlock? _bulletPreview, _numberPreview; // current list marker shown in the list combo boxes @@ -289,7 +289,7 @@ Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Dis }; // Reset reflected controls to null so Sync() null-guards whatever subset this level builds. - _undoBtn = _redoBtn = _boldBtn = _italicBtn = _underlineBtn = _strikeBtn = _bulletBtn = _numberBtn = null; + _undoBtn = _redoBtn = _boldBtn = _italicBtn = _underlineBtn = _strikeBtn = _bulletBtn = _numberBtn = _quoteBtn = null; _fontCombo = _sizeCombo = _headingCombo = _alignCombo = null; _spacingBox = null; _bulletPreview = _numberPreview = null; _tableBtn = _imageBtn = _dividerBtn = _findBtn = null; @@ -422,7 +422,10 @@ Control FindButton() => Btn("🔎", Loc("Find") + " (" + RichEditorShortcuts.Dis (ListMarkerStyle.LowerRoman, "i)")); _numberBtn = number.Icon; _numberPreview = number.Preview; Add(number.Box); - // Quote (blockquote) is available via the right-click List menu and ToggleQuote(); no toolbar button. + // Quote (blockquote): beside the lists — without it the default UI had no way to set one (the right-click + // item exists only with ShowFormattingMenu, and there is no shortcut). User decision, 2026-09-23. + _quoteBtn = Btn("❝", Loc("Quote"), () => Target?.ToggleQuote(), RichEditorIcon.Quote); + Add(_quoteBtn); Add(Btn("→|", Loc("IndentIncrease"), () => Target?.Indent(20), RichEditorIcon.IndentIncrease)); Add(Btn("|←", Loc("IndentDecrease"), () => Target?.Indent(-20), RichEditorIcon.IndentDecrease)); Add(Div()); @@ -914,6 +917,7 @@ static void SetActive(Button? b, bool active) SetActive(_strikeBtn, f.Strike); SetActive(_bulletBtn, f.List == ListKind.Bullet); SetActive(_numberBtn, f.List == ListKind.Ordered); + SetActive(_quoteBtn, f.Quote); // List combo previews show the caret paragraph's current marker; dimmed (inactive) when the // caret isn't in that list kind, full-ink (active) when it is. if (_bulletPreview != null) diff --git a/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs b/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs index f183423..56849ce 100644 --- a/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs +++ b/src/AvaloniaRichEditor/Controls/ToolbarIcons.cs @@ -122,6 +122,11 @@ private static Control Build(double box, params (string Data, bool Fill)[] layer ("M6 3 H18 V21 H6 Z", false), ("M9 7 H15 V17 H9 Z", false)), + // The quote bar the editor draws, beside the lines it marks. + RichEditorIcon.Quote => Build(20, + ("M5 5 V19", false), + ("M9 7 H20 M9 12 H20 M9 17 H16", false)), + _ => null, }; diff --git a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs index 96f43ae..be8aa27 100644 --- a/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs +++ b/src/AvaloniaRichEditor/Formatters/HtmlDocumentFormatter.cs @@ -189,7 +189,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[]?, Avalonia.Media.Imaging.Bitmap?, double, double, string? else if (src.StartsWith("file:")) { if (_blockLocalFileImages) return (null, null, 0, 0, null); - var path = new Uri(src).LocalPath; + var uri = new Uri(src); + // A file on ANOTHER machine (file://host/share/…) is a UNC path, and on Windows 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. AllowLocalFileImages is about this + // machine's files; a network share is never read (measured: a 21 s connect attempt). + if (uri.IsUnc) return (null, null, 0, 0, null); + var path = uri.LocalPath; if (System.IO.File.Exists(path)) bytes = System.IO.File.ReadAllBytes(path); } if (bytes == null) return (null, null, 0, 0, null); @@ -690,6 +696,25 @@ private static (byte[]?, Avalonia.Media.Imaging.Bitmap?, double, double, string? catch (Exception ex) { RichEditorDiagnostics.Report(ex); return (null, null, 0, 0, null); } } + // 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 HTML + // and the clipboard HTML other applications receive — although the editor itself never launches it + // (user decision, 2026-09-23: 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, ""); @@ -739,7 +764,7 @@ private static void ParseInlines(HtmlNode node, Paragraph p, FontWeight weight = 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/AvaloniaRichEditor/PublicAPI.Unshipped.txt b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt index 091980e..fa179ca 100644 --- a/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt +++ b/src/AvaloniaRichEditor/PublicAPI.Unshipped.txt @@ -2,6 +2,7 @@ AvaloniaRichEditor.Controls.RichEditor.PageMargin.get -> AvaloniaRichEditor.Documents.PageMargins AvaloniaRichEditor.Controls.RichEditor.PageMargin.set -> void AvaloniaRichEditor.Controls.RichEditorIcon.PageMargin = 48 -> AvaloniaRichEditor.Controls.RichEditorIcon +AvaloniaRichEditor.Controls.RichEditorIcon.Quote = 49 -> AvaloniaRichEditor.Controls.RichEditorIcon AvaloniaRichEditor.Controls.RichEditorShortcut AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.get -> bool AvaloniaRichEditor.Controls.RichEditorShortcut.Alt.init -> void diff --git a/src/AvaloniaRichEditor/RichEditorLocalization.cs b/src/AvaloniaRichEditor/RichEditorLocalization.cs index 54c76e7..af1445c 100644 --- a/src/AvaloniaRichEditor/RichEditorLocalization.cs +++ b/src/AvaloniaRichEditor/RichEditorLocalization.cs @@ -85,6 +85,7 @@ public static class RichEditorLocalization ["IndentDecrease"] = "Decrease Indent", ["Margin"] = "Margin", ["MarginTop"] = "Top Margin", + ["MarginAuto"] = "Auto (one line)", ["MarginBottom"] = "Bottom Margin", ["MarginLeft"] = "Left Margin", ["MarginRight"] = "Right Margin", @@ -231,6 +232,7 @@ public static class RichEditorLocalization ["IndentDecrease"] = "내어쓰기 -", ["Margin"] = "여백", ["MarginTop"] = "위 여백", + ["MarginAuto"] = "자동 (한 줄)", ["MarginBottom"] = "아래 여백", ["MarginLeft"] = "왼쪽 여백", ["MarginRight"] = "오른쪽 여백", diff --git a/tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs b/tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs new file mode 100644 index 0000000..17f8814 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests.Render/RenderingAuditTests.cs @@ -0,0 +1,133 @@ +using System; +using System.Reflection; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media.Imaging; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests.Render; + +// Audit of RichEditor.Rendering.cs (2026-09-23). Real Skia: a picture that fails to decode, and what the +// renderer paints, can only be seen with a live codec and rasteriser. +public class RenderingAuditTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + private const int W = 400, H = 300; + + private static byte[] Pixels(RichEditor ed) + { + ed.Measure(new Size(W, double.PositiveInfinity)); + ed.Arrange(new Rect(0, 0, W, H)); + using var rtb = new RenderTargetBitmap(new PixelSize(W, H)); + rtb.Render(ed); + var buf = new byte[W * 4 * H]; + var handle = GCHandle.Alloc(buf, GCHandleType.Pinned); + try { rtb.CopyPixels(new PixelRect(0, 0, W, H), handle.AddrOfPinnedObject(), buf.Length, W * 4); } + finally { handle.Free(); } + return buf; + } + + // A picture whose bytes do not decode drew nothing — and did not advance the walk either, so everything + // after it was drawn its height too high, over where the picture should be, while clicks and the caret + // (which measure it) stayed where they belong. + [AvaloniaFact] + public void APictureThatDoesNotDecode_StillTakesItsHeight() + { + var img = new ImageBlock { Width = 200, Height = 150, MarginTop = 0, MarginBottom = 0 }; + img.SetImageData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, "image/png"); + var after = new Paragraph { Inlines = { new Run { Text = "after" } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(img); + doc.Blocks.Add(after); + var ed = new RichEditor { Document = doc }; + typeof(RichEditor).GetField("_caretPosition", NP)!.SetValue(ed, new TextPointer(after, 0)); + + Pixels(ed); + + var caret = (Point)typeof(RichEditor).GetField("_lastCaretPoint", NP)!.GetValue(ed)!; + Assert.True(caret.Y >= 150, $"the paragraph after a 150px picture was drawn at y={caret.Y}"); + } + + // The raster PDF fallback read every page's pixels as BGRA. Skia hands back RGBA on macOS (the lesson from + // round 33's CI), so there a red page came out blue. The bitmap's own format decides now. + [AvaloniaTheory] + [InlineData(true)] + [InlineData(false)] + public void RasterPdfPixels_FollowTheBitmapsChannelOrder(bool rgba) + { + var fmt = rgba ? Avalonia.Platform.PixelFormat.Rgba8888 : Avalonia.Platform.PixelFormat.Bgra8888; + using var wb = new WriteableBitmap(new PixelSize(2, 2), new Vector(96, 96), fmt, Avalonia.Platform.AlphaFormat.Opaque); + using (var fb = wb.Lock()) + { + var red = rgba ? new byte[] { 255, 0, 0, 255 } : new byte[] { 0, 0, 255, 255 }; + for (int i = 0; i < 4; i++) + Marshal.Copy(red, 0, fb.Address + (i / 2) * fb.RowBytes + (i % 2) * 4, 4); + } + + var (_, _, rgb) = ((int, int, byte[]))typeof(RichEditor) + .GetMethod("BitmapToRgb24", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, new object[] { wb })!; + + Assert.Equal(new byte[] { 255, 0, 0 }, rgb[..3]); + } + + // Brushes.Silver, the quote bar's colour — distinct from the table's gray border (128) and the text. + private static bool IsSilver(byte[] px, int x, int y) + { + int o = (y * W + x) * 4; + return Math.Abs(px[o] - 192) < 6 && Math.Abs(px[o + 1] - 192) < 6 && Math.Abs(px[o + 2] - 192) < 6; + } + + private static int SilverPixels(byte[] px) + { + int n = 0; + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) + if (IsSilver(px, x, y)) n++; + return n; + } + + // Quote on a paragraph in a table cell changed nothing on screen: the cell walk drew no bar (nor a + // paragraph background), so 인용 in a cell looked like it did nothing. + [AvaloniaFact] + public void AQuoteInACell_DrawsItsBar() + { + var tb = new TableBlock(1, 1) { MarginTop = 0 }; + tb.ColumnWidths[0] = 300; + tb.Cells[0][0].Para.Inlines.Clear(); + tb.Cells[0][0].Para.Inlines.Add(new Run { Text = "quoted" }); + tb.Cells[0][0].Para.IsQuote = true; // no indent, as ToggleQuote leaves it + var doc = new FlowDocument(); + doc.Blocks.Add(tb); + + Assert.True(SilverPixels(Pixels(new RichEditor { Document = doc })) > 10, "no quote bar in the cell"); + } + + // An EMPTY paragraph in a quote broke the bar: the empty-paragraph path returned before drawing it. + [AvaloniaFact] + public void AnEmptyQuotedParagraph_KeepsTheBar() + { + Paragraph Q(string t) => new() { IsQuote = true, Inlines = { new Run { Text = t } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(Q("first")); + doc.Blocks.Add(Q("")); + doc.Blocks.Add(Q("third")); + var px = Pixels(new RichEditor { Document = doc }); + + // The bar's column, and its run of rows from the top of the first to the bottom of the third. + int bx = -1, top = -1, bottom = -1; + for (int x = 0; x < W && bx < 0; x++) + for (int y = 0; y < H; y++) + if (IsSilver(px, x, y)) { bx = x; break; } + Assert.True(bx >= 0, "no quote bar at all"); + for (int y = 0; y < H; y++) + if (IsSilver(px, bx, y)) { if (top < 0) top = y; bottom = y; } + // A gap is nothing painted: the control has no background, so alpha 0 (index 3 in both BGRA and RGBA). + // Where two paragraphs' bars meet at a fractional y the seam row is only part-covered (it is there between + // any two quoted lines, empty or not), which is no gap. Before the fix the empty line's rows read 0. + for (int y = top; y <= bottom; y++) + Assert.True(px[(y * W + bx) * 4 + 3] > 50, $"the quote bar has a gap at y={y}"); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs b/tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs new file mode 100644 index 0000000..576676c --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/BlockInsertPlacementTests.cs @@ -0,0 +1,96 @@ +using System.Linq; +using Avalonia; +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Where a block (table, picture, divider) goes when the caret is in a paragraph (user decision, 2026-09-23): the +// rule the object drag already had. In the middle the paragraph splits and the block goes between; at its start +// the block goes before it; at its end (or in an empty one) after it. Every insert used to go AFTER the caret's +// paragraph, so a picture pasted over a word in the middle of a line landed below the whole paragraph. +public class BlockInsertPlacementTests +{ + private static string Text(Block b) => b is Paragraph p ? string.Concat(p.Inlines.OfType().Select(r => r.Text)) : b.GetType().Name; + + private static string[] Shape(RichEditor ed) => ed.Document!.Blocks.Select(Text).ToArray(); + + private static InteractionHost Host(params Block[] blocks) + { + var doc = new FlowDocument(); + foreach (var b in blocks) doc.Blocks.Add(b); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + return host; + } + + private static void CaretAt(InteractionHost host, Paragraph p, int off) + { + var f = typeof(RichEditor).GetField("_caretPosition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + f.SetValue(host.Editor, new TextPointer(p, off)); + typeof(RichEditor).GetMethod("CollapseSelectionToCaret", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(host.Editor, null); + } + + [AvaloniaTheory] + [InlineData(4, new[] { "abcd", "DividerBlock", "efgh" })] // middle: split, between + [InlineData(0, new[] { "DividerBlock", "abcdefgh" })] // start: before (NormalizeBlocks adds one ahead) + [InlineData(8, new[] { "abcdefgh", "DividerBlock", "" })] // end: after, as before + public void ADividerGoesWhereTheCaretIs(int offset, string[] expected) + { + var p = new Paragraph { Inlines = { new Run { Text = "abcdefgh" } } }; + var host = Host(p); + CaretAt(host, p, offset); + + host.Editor.InsertDivider(); + + var shape = Shape(host.Editor); + // At the start, the document's first block may be a paragraph NormalizeBlocks put ahead of the divider. + if (offset == 0) shape = shape.SkipWhile(s => s == "").ToArray(); + Assert.Equal(expected, shape); + } + + // The same in a table cell: the cell's block list splits. + [AvaloniaFact] + public void InACell_TheCellParagraphSplits() + { + var tb = new TableBlock(1, 1); + var cp = tb.Cells[0][0].Para; + ((Run)cp.Inlines[0]).Text = "abcdefgh"; + var host = Host(new Paragraph { Inlines = { new Run { Text = "top" } } }, tb); + CaretAt(host, cp, 4); + + host.Editor.InsertDivider(); + + Assert.Equal(new[] { "abcd", "DividerBlock", "efgh" }, tb.Cells[0][0].Blocks.Select(Text).ToArray()); + } + + // A split heading stays a heading on both sides — Enter's "the next line is body text" is a typing rule. + [AvaloniaFact] + public void SplittingAHeading_KeepsBothHalvesHeadings() + { + var p = new Paragraph { HeadingLevel = 2, Inlines = { new Run { Text = "abcdefgh" } } }; + var host = Host(p); + CaretAt(host, p, 4); + + host.Editor.InsertDivider(); + + Assert.All(host.Editor.Document!.Blocks.OfType().Where(x => Text(x).Length > 0), x => Assert.Equal(2, x.HeadingLevel)); + } + + // One undo step brings the paragraph back whole. + [AvaloniaFact] + public void Undo_RejoinsTheParagraph() + { + var p = new Paragraph { Inlines = { new Run { Text = "abcdefgh" } } }; + var host = Host(p); + CaretAt(host, p, 4); + + host.Editor.InsertDivider(); + host.Editor.Undo(); + + Assert.Equal(new[] { "abcdefgh" }, Shape(host.Editor)); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs b/tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs new file mode 100644 index 0000000..dae8754 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/ClipboardAuditTests.cs @@ -0,0 +1,94 @@ +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Input.Platform; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.Clipboard.cs (2026-09-23). +public class ClipboardAuditTests +{ + // Pasting over a selection replaces it — the text, rich and inline-picture paths all delete it first. The + // spreadsheet (TSV -> table) path did not: the table went in after the caret's paragraph and the selected + // text stayed. + [AvaloniaFact] + public async Task PastingSpreadsheetCells_OverASelection_ReplacesIt() + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "keep REPLACE keep" } } }); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + var clipboard = TopLevel.GetTopLevel(host.Editor)?.Clipboard; + Assert.NotNull(clipboard); // precondition: the headless platform has one + await clipboard!.SetTextAsync("a\tb\nc\td"); + Assert.Equal("a\tb\nc\td", await clipboard.TryGetTextAsync()); // precondition: it round-trips + + host.Editor.FindNext("REPLACE", true); // selects the word + await host.Editor.PasteFromClipboardAsync(); + + Assert.Single(host.Editor.Document!.Blocks.OfType()); // precondition: it became a table + Assert.DoesNotContain("REPLACE", host.Editor.GetPlainText()); + host.Editor.Undo(); // one step back to the text, selection deleted and table inserted together + Assert.Contains("keep REPLACE keep", host.Editor.GetPlainText()); + Assert.Empty(host.Editor.Document!.Blocks.OfType()); + } + + // What a copy hands other applications (CF_HTML) is built from a trimmed copy of the selected paragraphs. + // That copy took a hand-picked list of paragraph fields and missed the margins, which the HTML writer does + // emit — so a spaced paragraph pasted into Word lost its spacing. Paragraph.CopyFormatFrom is the one list. + [AvaloniaFact] + public void TheCopyForOtherApps_KeepsParagraphMargins() + { + var p = new Paragraph { MarginTop = 20, MarginBottom = 12, Inlines = { new Run { Text = "spaced" } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(p); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + host.Editor.FindNext("spaced", true); + + var copy = (FlowDocument)typeof(RichEditor).GetMethod("BuildSelectionDocument", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.Invoke(host.Editor, null)!; + + var cp = Assert.IsType(Assert.Single(copy.Blocks)); + Assert.Equal((20.0, 12.0), (cp.MarginTop, cp.MarginBottom)); + } + + // A decodable 2x2 PNG — big enough (the in-app image format aside) that a clipboard picture pastes as a block. + private static readonly byte[] Png = System.Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + // The same for a picture on the clipboard (a screenshot): it went in beside the selection, which stayed. + [AvaloniaFact] + public async Task PastingAPicture_OverASelection_ReplacesIt() + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "keep REPLACE keep" } } }); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + var clipboard = TopLevel.GetTopLevel(host.Editor)!.Clipboard!; + var item = DataTransferItem.Create(DataFormat.CreateBytesPlatformFormat("PNG"), Png); + var dt = new DataTransfer(); + dt.Add(item); + await clipboard.SetDataAsync(dt); + + host.Editor.FindNext("REPLACE", true); + await host.Editor.PasteFromClipboardAsync(); + + Assert.Single(host.Editor.Document!.Blocks.OfType()); // precondition: the picture went in + Assert.DoesNotContain("REPLACE", host.Editor.GetPlainText()); + // Where the selection was — between its two halves (user decision 2026-09-23), not below the paragraph. + var blocks = host.Editor.Document!.Blocks; + int ii = blocks.IndexOf(blocks.OfType().Single()); + Assert.Equal("keep ", string.Concat(((Paragraph)blocks[ii - 1]).Inlines.OfType().Select(r => r.Text))); + Assert.Equal(" keep", string.Concat(((Paragraph)blocks[ii + 1]).Inlines.OfType().Select(r => r.Text))); + host.Editor.Undo(); + Assert.Contains("keep REPLACE keep", host.Editor.GetPlainText()); + Assert.Empty(host.Editor.Document!.Blocks.OfType()); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs b/tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs new file mode 100644 index 0000000..b5e1361 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/ContextMenuAuditTests.cs @@ -0,0 +1,324 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.ContextMenu.cs (2026-09-23): the file had no test naming its own members. Each case was +// measured red before its fix. +public class ContextMenuAuditTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + + private static string L(string key) => RichEditorLocalization.GetString(key); + + private static List AllItems(RichEditor ed) + { + var menu = (ContextMenu)typeof(RichEditor).GetField("_openContextMenu", NP)!.GetValue(ed)!; + var result = new List(); + void Walk(IEnumerable items) + { + foreach (var mi in items.OfType()) + { + result.Add(mi); + Walk((mi.ItemsSource ?? mi.Items)!.Cast()); + } + } + Walk((menu.ItemsSource ?? menu.Items)!.Cast()); + return result; + } + + private static MenuItem Sub(MenuItem parent, string header) + => ((parent.ItemsSource ?? parent.Items)!).OfType().Single(m => (m.Header as string) == header); + + private static void Invoke(MenuItem mi) => mi.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + + private static TableBlock Table() + { + var tb = new TableBlock(2, 2); + for (int r = 0; r < 2; r++) + for (int c = 0; c < 2; c++) + ((Run)tb.Cells[r][c].Para.Inlines[0]).Text = $"c{r}{c}"; + return tb; + } + + private static InteractionHost Host(params Block[] blocks) + { + var doc = new FlowDocument(); + foreach (var b in blocks) doc.Blocks.Add(b); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + return host; + } + + private static Point OnLeftBorder(RichEditor ed, TableBlock tb) + { + var rect = typeof(RichEditor).GetMethod("GetTableRect", NP)!.Invoke(ed, new object[] { tb })!; + double top = (double)rect.GetType().GetField("Item1")!.GetValue(rect)!; + return new Point(10 + tb.Indent, top + 6); + } + + private static Point InsideFirstCell(InteractionHost host, TableBlock tb) + { + var row0 = host.RowHandles.First(r => ReferenceEquals(r.tb, tb) && r.rowIndex == 0); + return new Point(row0.rect.Left + 20, row0.rect.Center.Y - row0.height / 2); + } + + // A left click clears the block caret on every press; the right-click returned before that line, so a table + // held by its border stayed held after a right-click somewhere else — the caret moved to the clicked text + // while the block caret still claimed the table. + [AvaloniaFact] + public void RightClickingText_DropsABlockCaretLeftOnATable() + { + var tb = Table(); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above the table" } } }, tb); + host.Click(OnLeftBorder(host.Editor, tb)); + Assert.Same(tb, host.CaretBlock); // precondition + + host.Click(new Point(20, 8), MouseButton.Right); + + Assert.Null(host.CaretBlock); + } + + // …and inside the same table's cell: the stale block caret put the menu in "table held whole" mode, greying + // 셀 선택 and the row/column items for a cell the user had just clicked. + [AvaloniaFact] + public void RightClickingACell_AfterTheBorderWasClicked_OffersTheCellItems() + { + var tb = Table(); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above the table" } } }, tb); + host.Click(OnLeftBorder(host.Editor, tb)); + Assert.Same(tb, host.CaretBlock); // precondition + + host.Click(InsideFirstCell(host, tb), MouseButton.Right); + + var items = AllItems(host.Editor); + Assert.True(items.First(i => (i.Header as string) == L("SelectCell")).IsEnabled); + Assert.True(items.First(i => (i.Header as string) == L("InsertRowAbove")).IsEnabled); + } + + // Control: a right-click ON the border still holds the table (the 2026-09-13 behaviour). + [AvaloniaFact] + public void RightClickingTheBorder_StillHoldsTheTable() + { + var tb = Table(); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above the table" } } }, tb); + + host.Click(OnLeftBorder(host.Editor, tb), MouseButton.Right); + + Assert.Same(tb, host.CaretBlock); + } + + // Every other public insert (InsertTable, InsertImage) refuses in a read-only editor; InsertDivider did not. + [AvaloniaFact] + public void InsertDivider_DoesNothingWhenReadOnly() + { + var host = Host(new Paragraph { Inlines = { new Run { Text = "text" } } }); + host.Editor.IsReadOnly = true; + + host.Editor.InsertDivider(); + + Assert.DoesNotContain(host.Editor.Document!.Blocks, b => b is DividerBlock); + Assert.False(host.Editor.CanUndo); + } + + private static readonly byte[] Png = System.Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + private static (InteractionHost host, ImageBlock img) HostWithImage() + { + var img = new ImageBlock { Width = 300, Height = 300 }; + img.SetImageData(Png, "image/png"); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above" } } }, img, + new Paragraph { Inlines = { new Run { Text = "below" } } }); + host.Click(new Point(80, 120), MouseButton.Right); + return (host, img); + } + + private static MenuItem BottomPreset(RichEditor ed, string px) + { + var margin = AllItems(ed).Single(i => (i.Header as string) == L("Margin")); + return Sub(Sub(margin, L("MarginBottom")), px); + } + + // Picking the margin already in force changed nothing but left an undo step that undid nothing — the cell + // vertical-alignment radio beside it already skips its own current value. + [AvaloniaFact] + public void MarginPreset_AlreadyInForce_LeavesNoUndoStep() + { + var (host, img) = HostWithImage(); + Assert.Equal(10, img.MarginBottom); // precondition: the default bottom margin + Assert.False(host.Editor.CanUndo); // precondition + + Invoke(BottomPreset(host.Editor, "10 px")); + + Assert.False(host.Editor.CanUndo); + } + + private static void CloseMenu(RichEditor ed) + => ((ContextMenu)typeof(RichEditor).GetField("_openContextMenu", NP)!.GetValue(ed)!).Close(); + + private static MenuItem Item(RichEditor ed, string key) => AllItems(ed).First(i => (i.Header as string) == L(key)); + + // After a table goes away through the menu, typing has to land in the document. The caret stayed in a cell of + // the removed table (and a border's block caret on the table itself), so the keys typed into a paragraph that + // is no longer in the document — the text vanished. + [AvaloniaTheory] + [InlineData(false)] // right-click inside a cell: the text menu's "Table" submenu + [InlineData(true)] // right-click on the border: the table menu + public void DeleteTableFromTheMenu_ThenTyping_LandsInTheDocument(bool onBorder) + { + var tb = Table(); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above the table" } } }, tb); + host.Click(onBorder ? OnLeftBorder(host.Editor, tb) : InsideFirstCell(host, tb), MouseButton.Right); + + Invoke(Item(host.Editor, "DeleteTable")); + CloseMenu(host.Editor); + host.Type("Z"); + + Assert.DoesNotContain(host.Editor.Document!.Blocks, b => b is TableBlock); // precondition: it went + Assert.Contains("Z", host.Editor.GetPlainText()); + } + + private static (InteractionHost host, TableBlock inner) HostWithInlineTable() + { + var inner = Table(); + var p = new Paragraph(); + p.Inlines.Add(new Run { Text = "before " }); + p.Inlines.Add(new InlineTable { Table = inner }); + p.Inlines.Add(new Run { Text = " after" }); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above" } } }, p); + return (host, inner); + } + + private static Rect NestedRect(RichEditor ed, TableBlock tb) + => ((List<(Rect rect, TableBlock tb)>)typeof(RichEditor).GetField("_nestedTableRects", NP)!.GetValue(ed)!) + .Last(x => ReferenceEquals(x.tb, tb)).rect; + + // The same for "treat as character" unchecked on an inline table: the table is re-created as a block (a clone), + // and the caret was left in a cell of the inline original, which is gone. + [AvaloniaFact] + public void InlineTableToBlockFromTheMenu_ThenTyping_LandsInTheDocument() + { + var (host, inner) = HostWithInlineTable(); + var r = NestedRect(host.Editor, inner); + host.Click(new Point(r.Left + 20, r.Top + 8), MouseButton.Right); // inside its first cell + + Invoke(Item(host.Editor, "InlineWithText")); + CloseMenu(host.Editor); + host.Type("Z"); + + Assert.Contains(host.Editor.Document!.Blocks, b => b is TableBlock); // precondition: it became a block + Assert.Contains("Z", host.Editor.GetPlainText()); + } + + // …and the other way: a block table held by its border and made a character. The block caret still named the + // removed table. + [AvaloniaFact] + public void TableBlockToInlineFromTheBorderMenu_LeavesNoBlockCaretOnTheRemovedTable() + { + var tb = Table(); + var host = Host(new Paragraph { Inlines = { new Run { Text = "above the table" } } }, tb); + host.Click(OnLeftBorder(host.Editor, tb), MouseButton.Right); + Assert.Same(tb, host.CaretBlock); // precondition + + Invoke(Item(host.Editor, "InlineWithText")); + + Assert.Null(host.CaretBlock); + CloseMenu(host.Editor); + host.Type("Z"); + Assert.Contains("Z", host.Editor.GetPlainText()); + } + + private static MenuItem TopPreset(RichEditor ed, string header) + { + var margin = AllItems(ed).Single(i => (i.Header as string) == L("Margin")); + return Sub(Sub(margin, L("MarginTop")), header); + } + + // A picture's top is "auto" (one line gap) by default since PR #52. No px preset showed it, and once one was + // picked nothing could bring it back (user decision, 2026-09-23: an Auto item). + [AvaloniaFact] + public void TopMargin_OffersAuto_CheckedByDefault_AndRestoresIt() + { + var (host, img) = HostWithImage(); + Assert.True(double.IsNaN(img.MarginTop)); // precondition: the default + Assert.True(TopPreset(host.Editor, L("MarginAuto")).IsChecked); + + Invoke(TopPreset(host.Editor, "10 px")); + Assert.Equal(10, img.MarginTop); + CloseMenu(host.Editor); + host.Click(new Point(80, 120), MouseButton.Right); + Invoke(TopPreset(host.Editor, L("MarginAuto"))); + + Assert.True(double.IsNaN(img.MarginTop)); + } + + // A paragraph has no "auto" top: its gap is the line spacing's. + [AvaloniaFact] + public void ParagraphTopMargin_HasNoAuto() + { + var host = Host(new Paragraph { Inlines = { new Run { Text = "text" } } }); + host.Editor.ShowFormattingMenu = true; + host.Click(new Point(10, 8), MouseButton.Right); + + Assert.DoesNotContain(AllItems(host.Editor), i => (i.Header as string) == L("MarginAuto")); + } + + // Tab in the last cell of a table that is NOT the document's last jumped into the next table below; it adds a + // row, as it always did in the last table (user decision, 2026-09-23). + [AvaloniaFact] + public void TabInTheLastCell_OfAnEarlierTable_AddsARow() + { + var first = Table(); + var second = Table(); + var host = Host(new Paragraph { Inlines = { new Run { Text = "a" } } }, first, + new Paragraph { Inlines = { new Run { Text = "between" } } }, second); + host.Click(InsideFirstCell(host, first)); + host.Key(Key.Tab); host.Key(Key.Tab); host.Key(Key.Tab); // to the last cell, c11 + + host.Key(Key.Tab); + + Assert.Equal(3, first.Rows); + Assert.Equal(2, second.Rows); + } + + // Control: inside a cell's nested table, Tab past its last cell still steps out to the host's next cell. + [AvaloniaFact] + public void TabInTheLastCell_OfANestedTable_StillStepsOut() + { + var inner = Table(); + var outer = new TableBlock(1, 2); + outer.Cells[0][0].Blocks.Clear(); + outer.Cells[0][0].Blocks.Add(inner); + outer.Cells[0][0].Blocks.Add(new Paragraph { Inlines = { new Run { Text = "after" } } }); + var host = Host(new Paragraph { Inlines = { new Run { Text = "a" } } }, outer); + typeof(RichEditor).GetMethod("FocusCell", NP)!.Invoke(host.Editor, new object[] { inner.Cells[1][1].Para }); + + host.Key(Key.Tab); + + Assert.Equal(2, inner.Rows); + Assert.Equal(1, outer.Rows); + Assert.Same(outer.Cells[0][1].Para, host.Caret.Paragraph); + } + + // Control: a different preset still applies and can be undone. + [AvaloniaFact] + public void MarginPreset_NewValue_AppliesWithAnUndoStep() + { + var (host, img) = HostWithImage(); + + Invoke(BottomPreset(host.Editor, "20 px")); + + Assert.Equal(20, img.MarginBottom); + Assert.True(host.Editor.CanUndo); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/DragBlockAuditTests.cs b/tests/AvaloniaRichEditor.Tests/DragBlockAuditTests.cs new file mode 100644 index 0000000..53074b5 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/DragBlockAuditTests.cs @@ -0,0 +1,68 @@ +using System.Linq; +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.DragBlock.cs (2026-09-23). +public class DragBlockAuditTests +{ + private static readonly byte[] Png = System.Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + private static (RichEditor ed, Paragraph target) Editor(Block obj) + { + var target = new Paragraph { Inlines = { new Run { Text = "drop here" } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "top" } } }); + doc.Blocks.Add(obj); + doc.Blocks.Add(target); + var ed = new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }; + InteractionHost.Create(ed).Render(); + return (ed, target); + } + + // Ctrl+drag makes a NEW table or picture. With AllowTables / AllowImages off the editor creates neither — + // not by the menu, not by paste (AdaptToCapabilities) — but a drag copy did. + [AvaloniaFact] + public void CopyDraggingATable_WithTablesOff_AddsNoTable() + { + var tb = new TableBlock(1, 1); + var (ed, target) = Editor(tb); + ed.AllowTables = false; + + ed.DropObject(tb, new TextPointer(target, target.Inlines.OfType().First().Text!.Length), copy: true); + + Assert.Single(ed.Document!.Blocks.OfType()); + } + + [AvaloniaFact] + public void CopyDraggingAPicture_WithImagesOff_AddsNoPicture() + { + var img = new ImageBlock { Width = 20, Height = 20 }; + img.SetImageData(Png, "image/png"); + var (ed, target) = Editor(img); + ed.AllowImages = false; + + ed.DropObject(img, new TextPointer(target, 9), copy: true); + + Assert.Single(ed.Document!.Blocks.OfType()); + } + + // Control: MOVING an existing table stays allowed — editing a table the document already has is not + // creating one (the same line the row/column commands draw). + [AvaloniaFact] + public void MoveDraggingATable_WithTablesOff_StillMovesIt() + { + var tb = new TableBlock(1, 1); + var (ed, target) = Editor(tb); + ed.AllowTables = false; + + bool changed = ed.DropObject(tb, new TextPointer(target, 9), copy: false); + + Assert.True(changed); + Assert.Single(ed.Document!.Blocks.OfType()); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/FindReplaceAuditTests.cs b/tests/AvaloniaRichEditor.Tests/FindReplaceAuditTests.cs new file mode 100644 index 0000000..765dc5c --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/FindReplaceAuditTests.cs @@ -0,0 +1,75 @@ +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.FindReplace.cs (2026-09-23). +public class FindReplaceAuditTests +{ + private static InteractionHost Host() + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "hello world hello" } } }); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + host.Click(new Avalonia.Point(5, 8)); + return host; + } + + // The find bar checks IsReadOnly before replacing; the public methods it calls did not, so a host driving + // them (its own find UI) rewrote a viewer's document. + [AvaloniaFact] + public void ReplaceAll_DoesNothingWhenReadOnly() + { + var host = Host(); + host.Editor.IsReadOnly = true; + + int n = host.Editor.ReplaceAll("hello", "bye", false); + + Assert.Equal(0, n); + Assert.Contains("hello world hello", host.Editor.GetPlainText()); + } + + [AvaloniaFact] + public void ReplaceNext_DoesNotReplaceWhenReadOnly() + { + var host = Host(); + host.Editor.FindNext("hello", false); // the selection is on a match + host.Editor.IsReadOnly = true; + + host.Editor.ReplaceNext("hello", "bye", false); + + Assert.Contains("hello world hello", host.Editor.GetPlainText()); + } + + // Replace All with nothing to replace pushed an undo step and flagged the document modified. + [AvaloniaFact] + public void ReplaceAll_WithNoMatch_LeavesTheDocumentUnmodified() + { + var host = Host(); + host.Editor.MarkSaved(); // a raw Document assignment counts as a change by design + Assert.False(host.Editor.IsModified); // precondition + + int n = host.Editor.ReplaceAll("absent", "x", false); + + Assert.Equal(0, n); + Assert.False(host.Editor.CanUndo); + Assert.False(host.Editor.IsModified); + } + + // Control: with matches it replaces them all, as one undo step. + [AvaloniaFact] + public void ReplaceAll_WithMatches_ReplacesThemAsOneStep() + { + var host = Host(); + + int n = host.Editor.ReplaceAll("hello", "bye", false); + + Assert.Equal(2, n); + Assert.Contains("bye world bye", host.Editor.GetPlainText()); + host.Editor.Undo(); + Assert.Contains("hello world hello", host.Editor.GetPlainText()); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/FormattingAuditTests.cs b/tests/AvaloniaRichEditor.Tests/FormattingAuditTests.cs new file mode 100644 index 0000000..3782961 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/FormattingAuditTests.cs @@ -0,0 +1,59 @@ +using System; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.Formatting.cs (2026-09-23). +public class FormattingAuditTests +{ + private static InteractionHost Host() + { + var doc = new FlowDocument(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "hello world" } } }); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + host.Click(new Avalonia.Point(10, 8)); // a caret: every paragraph command returns early without one + return host; + } + + // A paragraph command that changes nothing — outdenting at indent 0 (Shift+Tab's fallback), aligning left what is + // left already — left an undo step that undid nothing: Ctrl+Z then seemed to do nothing. + [AvaloniaTheory] + [InlineData("outdent")] + [InlineData("align")] + [InlineData("spacing")] + [InlineData("height")] + [InlineData("removelist")] + public void AParagraphCommandThatChangesNothing_LeavesNoUndoStep(string cmd) + { + var host = Host(); + var ed = host.Editor; + var p = (Paragraph)ed.Document!.Blocks[0]; + Assert.False(ed.CanUndo); // precondition + + Action run = cmd switch + { + "outdent" => () => ed.Indent(-20), + "align" => () => ed.SetTextAlignment(p.TextAlignment), + "spacing" => () => ed.SetLineSpacing(p.LineSpacing), + "height" => () => ed.SetLineHeight(p.LineHeight), + _ => ed.RemoveList, + }; + run(); + + Assert.False(ed.CanUndo); + } + + // Control: a command that does change the paragraph still records its step. + [AvaloniaFact] + public void AParagraphCommandThatChangesSomething_StillLeavesAnUndoStep() + { + var host = Host(); + host.Editor.SetTextAlignment(TextAlignment.Center); + Assert.True(host.Editor.CanUndo); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/HitTestingAuditTests.cs b/tests/AvaloniaRichEditor.Tests/HitTestingAuditTests.cs new file mode 100644 index 0000000..110505e --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/HitTestingAuditTests.cs @@ -0,0 +1,53 @@ +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.HitTesting.cs (2026-09-23). +public class HitTestingAuditTests +{ + private static bool Inside(object? e, TableBlock tb) + { + for (var cur = e; cur != null; cur = (cur as TextElement)?.Parent) + if (ReferenceEquals(cur, tb)) return true; + return false; + } + + // ↓ into a table checks that the point it resolved lies in that table (IsCellOf). IsCellOf walked cell + // blocks and nested tables but not an INLINE table in a cell's paragraph, so when the caret's column met + // one, the table was judged "not entered" and ↓ fell back to the block caret beside it. + [AvaloniaFact] + public void Down_IntoARowWhoseCellHoldsAnInlineTable_EntersTheTable() + { + var inner = new TableBlock(2, 2); + inner.ColumnWidths[0] = inner.ColumnWidths[1] = 120; + var outer = new TableBlock(1, 1) { MarginTop = 0 }; + outer.ColumnWidths[0] = 300; + outer.Cells[0][0].Para.Inlines.Clear(); + outer.Cells[0][0].Para.Inlines.Add(new InlineTable { Table = inner }); + var above = new Paragraph { Inlines = { new Run { Text = "above it" } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(above); + doc.Blocks.Add(outer); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + host.Click(new Point(5, 8)); + host.Key(Key.End); // the caret x ↓ carries: well inside the inline table's columns + host.Render(); + // The block caret before the table — the path that asks IsCellOf (TryEnterTableRow). It keeps the + // text caret's last x. + typeof(RichEditor).GetField("_caretBlock", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .SetValue(host.Editor, outer); + Assert.Same(outer, host.CaretBlock); // precondition + + host.Key(Key.Down); + + Assert.Null(host.CaretBlock); + Assert.True(Inside(host.Caret.Paragraph, outer), "↓ did not enter the table"); + Assert.True(Inside(host.Caret.Paragraph, inner), "the landing point missed the inline table — the case is not exercised"); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs b/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs new file mode 100644 index 0000000..cd4492f --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/HtmlAuditTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Documents; +using AvaloniaRichEditor.Formatters; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of HtmlDocumentFormatter.cs (2026-09-23). +public class HtmlAuditTests +{ + // A pasted page's script link was kept as the run's address and written back out as an — into + // exported HTML and the clipboard HTML other applications receive. The editor never launches it (only + // http/https open), but it laundered the link into its own output. + [AvaloniaTheory] + [InlineData("javascript:alert(1)")] + [InlineData("JavaScript:alert(1)")] + [InlineData(" javascript:alert(1)")] + [InlineData("java\tscript:alert(1)")] // browsers drop tabs and newlines inside the scheme + [InlineData("vbscript:msgbox(1)")] + [InlineData("data:text/html,")] + 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); + } + + // A file: image on ANOTHER machine (file://host/share/x.png) is a UNC path, and on Windows File.Exists on + // it opens an SMB connection to that host — which offers the user's NTLM credentials to it. Pasting HTML is + // enough to trigger it, and a page controls the its copied selection carries. Local file images + // are a host setting (AllowLocalFileImages); a remote share is never one. 192.0.2.1 is TEST-NET-1: + // unroutable, so an attempt shows up as the connect timeout (measured before the fix: see the assert). + [AvaloniaFact] + public void AnImageOnANetworkShare_IsNeverOpened() + { + var sw = System.Diagnostics.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); + } + + // Control: web and mail links stay. + [AvaloniaTheory] + [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); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/ImagesAuditTests.cs b/tests/AvaloniaRichEditor.Tests/ImagesAuditTests.cs new file mode 100644 index 0000000..1feba08 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/ImagesAuditTests.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Reflection; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.Images.cs (2026-09-23). +public class ImagesAuditTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + + // A decodable 1x1 PNG. + private static readonly byte[] Png = System.Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + private static void Call(RichEditor ed, string name, params object[] args) + => typeof(RichEditor).GetMethod(name, NP)!.Invoke(ed, args); + + // "ab" + an inline picture at the end, caret after the picture (End) — where a right-click on the picture's + // trailing half leaves it too. + private static (InteractionHost host, Paragraph p, InlineImage img) HostWithTrailingPicture() + { + var img = new InlineImage { Width = 16, Height = 16 }; + img.SetImageData(Png, "image/png"); + var p = new Paragraph { Inlines = { new Run { Text = "ab" }, img } }; + var doc = new FlowDocument(); + doc.Blocks.Add(p); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "next" } } }); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + host.Click(new Avalonia.Point(5, 8)); + host.Key(Key.End); + Assert.Same(p, host.Caret.Paragraph); // precondition + Assert.Equal(3, host.Caret.Offset); // precondition: after the picture + return (host, p, img); + } + + // Deleting the picture from its menu left the caret one past the paragraph's end. + [AvaloniaFact] + public void DeletingAnInlinePicture_KeepsTheCaretInsideItsParagraph() + { + var (host, p, img) = HostWithTrailingPicture(); + + Call(host.Editor, "DeleteInlineImage", p, img); + host.Type("Z"); + + Assert.Equal("abZ", string.Concat(p.Inlines.OfType().Select(r => r.Text))); + } + + // The same when the picture is promoted to a block (글자처럼 취급 off). + [AvaloniaFact] + public void MakingAnInlinePictureABlock_KeepsTheCaretInsideItsParagraph() + { + var (host, p, img) = HostWithTrailingPicture(); + + Call(host.Editor, "ConvertInlineImageToBlock", p, img); + host.Type("Z"); + + Assert.Equal("abZ", string.Concat(p.Inlines.OfType().Select(r => r.Text))); + } + + // "Original size" on a picture already at its natural size left an undo step that undid nothing. + [AvaloniaFact] + public void OriginalSize_OnAPictureAtItsNaturalSize_LeavesNoUndoStep() + { + var img = new ImageBlock { Width = 1, Height = 1 }; // the PNG is 1x1 + img.SetImageData(Png, "image/png"); + var doc = new FlowDocument(); + doc.Blocks.Add(img); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + Assert.False(host.Editor.CanUndo); // precondition + + Call(host.Editor, "ResetImageSize", img); + + Assert.False(host.Editor.CanUndo); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/InputAuditTests.cs b/tests/AvaloniaRichEditor.Tests/InputAuditTests.cs new file mode 100644 index 0000000..3d72b76 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/InputAuditTests.cs @@ -0,0 +1,56 @@ +using System.Linq; +using System.Reflection; +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditor.Input.cs (2026-09-23). +public class InputAuditTests +{ + private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + + private static readonly byte[] Png = System.Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + // "ab" + a selected inline picture, the caret after it (where a click on its trailing half leaves it). + private static (InteractionHost host, Paragraph p) HostWithSelectedTrailingPicture() + { + var img = new InlineImage { Width = 16, Height = 16 }; + img.SetImageData(Png, "image/png"); + var p = new Paragraph { Inlines = { new Run { Text = "ab" }, img } }; + var doc = new FlowDocument(); + doc.Blocks.Add(p); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + host.Render(); + host.Click(new Point(5, 8)); + host.Key(Key.End); + Assert.Equal(3, host.Caret.Offset); // precondition: after the picture + typeof(RichEditor).GetField("_selectedInline", NP)!.SetValue(host.Editor, ((Paragraph, InlineImage)?)(p, img)); + return (host, p); + } + + private static string Text(Paragraph p) => string.Concat(p.Inlines.OfType().Select(r => r.Text)); + + // Deleting a selected inline picture with Backspace/Delete, or cutting it with Ctrl+X, took its character out + // but left the caret where it was — one past the paragraph's end when it sat after the picture — so the next + // keys typed nowhere. The menu's Delete had the same defect (ImagesAuditTests). + [AvaloniaTheory] + [InlineData(Key.Back, RawInputModifiers.None)] + [InlineData(Key.Delete, RawInputModifiers.None)] + [InlineData(Key.X, RawInputModifiers.Control)] + public void RemovingASelectedInlinePicture_KeepsTheCaretInItsParagraph(Key key, RawInputModifiers mods) + { + var (host, p) = HostWithSelectedTrailingPicture(); + + host.Key(key, mods); + host.Type("Z"); + + Assert.DoesNotContain(p.Inlines, i => i is InlineImage); // precondition: it went + Assert.Equal("abZ", Text(p)); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/RtfAuditTests.cs b/tests/AvaloniaRichEditor.Tests/RtfAuditTests.cs new file mode 100644 index 0000000..3f2eaae --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/RtfAuditTests.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; +using System.Linq; +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using AvaloniaRichEditor.Formatters; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RtfDocumentFormatter.cs (2026-09-23). RTF arrives by paste — untrusted input — so the numbers in it +// are whatever the source says, including values no word processor would write. +public class RtfAuditTests +{ + // Hostile or corrupt sizes must not wreck the editor: the document still lays out and draws, quickly. + [AvaloniaTheory] + [InlineData(@"{\rtf1\ansi\fs2000000000 huge}")] // a billion-point font + [InlineData(@"{\rtf1\ansi\pard\sl2000000000\slmult1 tall\par}")] // a ten-million-times line + [InlineData(@"{\rtf1\ansi\pard\sl-240\slmult1 negative\par}")] // a negative proportional line + [InlineData(@"{\rtf1\ansi\pard\sl2000000000\slmult0 exact\par}")] // a 130-million-pixel exact line + public void AbsurdSizes_StillLayOutAndDraw(string rtf) + { + var doc = RtfDocumentFormatter.Parse(rtf); + var p = doc.Blocks.OfType().First(x => x.Inlines.OfType().Any(r => r.Text!.Length > 0)); + var run = p.Inlines.OfType().First(); + // Precondition: the absurd value really reached the model (measured: 1e9 pt, -1.2, 1e7, 1.3e8 px) — the + // layout, not the reader, is what copes with it, so this guards the layout. + Assert.True(run.FontSize > 1000 || p.LineSpacing is < 0 or > 1000 || p.LineHeight > 1_000_000, "the value never reached the model"); + var host = InteractionHost.Create(new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }); + + var sw = Stopwatch.StartNew(); + host.Render(); + host.Editor.InvalidateMeasure(); + host.Render(); + sw.Stop(); + + Assert.True(sw.ElapsedMilliseconds < 5000, $"layout took {sw.ElapsedMilliseconds} ms"); + Assert.True(double.IsFinite(host.Editor.DesiredSize.Height), $"height {host.Editor.DesiredSize.Height}"); + Assert.True(host.Editor.DesiredSize.Height < 1_000_000, $"height {host.Editor.DesiredSize.Height}"); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/ToolbarPageFileAuditTests.cs b/tests/AvaloniaRichEditor.Tests/ToolbarPageFileAuditTests.cs new file mode 100644 index 0000000..c751571 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/ToolbarPageFileAuditTests.cs @@ -0,0 +1,65 @@ +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Audit of RichEditorToolbar.PageFile.cs (2026-09-23). The toolbar mirrors the editor's page state onto its +// combos; a paper or zoom change that did NOT come from the combo must not be answered as if the user had picked it. +public class ToolbarPageFileAuditTests +{ + private static RichEditorToolbar Toolbar(RichEditor ed) => new() { Target = ed, ToolbarLevel = ToolbarLevel.Maximum }; + + private static FlowDocument DocWith(PageSetup setup) + { + var doc = new FlowDocument { PageSetup = setup }; + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "text" } } }); + return doc; + } + + // Control: a document that asks for A4 WITHOUT boundaries keeps them off (this path was already right — the + // document's setup is applied after the paper — and guards the fix below from breaking it). + [AvaloniaFact] + public void OpeningADocument_KeepsItsPageBoundariesOff() + { + var ed = new RichEditor(); + _ = Toolbar(ed); + + ed.Document = DocWith(new PageSetup { PageSize = RichEditorPageSize.A4, ShowPageBoundaries = false }); + + Assert.Equal(RichEditorPageSize.A4, ed.PageSize); // precondition + Assert.False(ed.ShowPageBoundaries); + Assert.False(ed.Document!.PageSetup!.ShowPageBoundaries); + } + + // A host setting PageSize in code with boundaries off: mirroring the paper onto the combo fired the combo's + // own handler, which switched boundaries on as a picked paper does. + [AvaloniaFact] + public void AHostSettingThePaper_KeepsItsPageBoundariesOff() + { + var ed = new RichEditor { ShowPageBoundaries = false }; + _ = Toolbar(ed); + + ed.PageSize = RichEditorPageSize.A4; + + Assert.False(ed.ShowPageBoundaries); + } + + // An off-grid zoom (Ctrl+wheel) empties the zoom combo; clearing it outside the sync guard fired its handler + // with index -1, which it reads as "Fit" — so a paper change snapped the host's zoom to fit-width. + [AvaloniaFact] + public void APaperChange_DoesNotSnapAnOffGridZoomToFit() + { + var ed = new RichEditor(); + var tb = Toolbar(ed); + int fits = 0; + tb.ZoomGetter = () => 1.1; + tb.IsFitWidthGetter = () => false; + tb.FitWidthAction = () => fits++; + + ed.PageSize = RichEditorPageSize.A4; + + Assert.Equal(0, fits); + } +} diff --git a/tests/AvaloniaRichEditor.Tests/ToolbarQuoteButtonTests.cs b/tests/AvaloniaRichEditor.Tests/ToolbarQuoteButtonTests.cs new file mode 100644 index 0000000..a5761d1 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/ToolbarQuoteButtonTests.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// Without a toolbar button the default UI had no way to set a quote: the right-click item exists only with +// ShowFormattingMenu, and there is no shortcut (user decision 2026-09-23: a button beside the lists). +public class ToolbarQuoteButtonTests +{ + [AvaloniaFact] + public void TheQuoteButton_TogglesTheCaretParagraph_AndShowsItsState() + { + var p = new Paragraph { Inlines = { new Run { Text = "text" } } }; + var doc = new FlowDocument(); + doc.Blocks.Add(p); + var ed = new RichEditor { Document = doc, PageSize = RichEditorPageSize.Continuous }; + var host = InteractionHost.Create(ed); + host.Render(); + host.Click(new Avalonia.Point(5, 8)); // a caret in the paragraph + var toolbar = new RichEditorToolbar { Target = ed }; + + var quote = toolbar.GetLogicalDescendants().OfType