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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ and follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### 클립보드가 잠깐 바쁠 때 복사·붙여넣기가 조용히 실패하던 것 (2026-09-24)

- 클립보드 기록(Win+V)·클립보드 관리자처럼 클립보드를 지켜보는 프로그램이 있으면, 변경 직후 잠깐 클립보드가
잠긴다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼키고 다음 형식으로 넘어가서, 웹 페이지가 평문으로
붙거나 아무것도 안 붙고, 복사가 사라졌다. 개발 PC 실측: 붙여넣기 150회 중 9회, 복사 300회 중 18회.
- 그 오류만 짧게(20 ms × 최대 10회) 다시 시도한다. 기다림은 **await**이다 — 잠근 쪽이 우리 UI 스레드에 데이터를
요청하는 중일 수 있어, `Thread.Sleep`으로 기다리면 끝까지 풀리지 않았다(실측: sleep 재시도로도 300회 중 5회 유실).
수정 후 붙여넣기 450회·복사 900회 유실 0.
- 잘라내기는 복사를 시작한 뒤 **바로** 지우고 나서 쓰기를 기다린다 — 기다리는 동안 들어온 입력이 선택을 옮겨
다른 곳을 지우지 않게. 링크 복사(우클릭)도 같은 재시도를 탄다(전에는 예외 처리가 없었다).
- 전체 테스트에서 가끔 실패하던 `ControlFeatureFlagTests.AllowRemoteImagesOnPaste_…PastingHtml…`의 원인이 이것이었다.

### 표 행·열 명령과 단축키 표 공개 — 상류 PR #48·#49 백포트 (2026-09-24)

**공개 API 추가**: `RichEditor.InsertTableRow`·`DeleteTableRow`·`InsertTableColumn`·`DeleteTableColumn`(표·인덱스 지정),
Expand Down Expand Up @@ -46,7 +58,6 @@ and follows [Semantic Versioning](https://semver.org/).
- 쪽 여백은 DIP로 바꿀 때 **정수로 반올림**한다(최대 0.13 mm, 파일에는 정확한 mm). 15 mm = 56.69 DIP라
내용 상자와 Win2D의 안티앨리어싱 클립이 소수점 픽셀에 걸려 가장자리 선이 흐려졌다(실측).


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

상류가 테스트가 닿지 않던 파일을 감사해 고친 결함들을 이 포트에서 **먼저 측정**했다. 빨강이 된 것만 고쳤고,
Expand Down Expand Up @@ -659,7 +670,6 @@ C:\Users\<빌드한 사람>\source\repos\WinUIRichEditor\src\WinUIRichEditor\For
- **Native AOT 재실측**: 네이티브 exe 14.5 MB, 게시 85.7 MB, 관리형 DLL 부재.
- **NuGet 소비자 스모크**: 별도 앱이 `PackageReference`로 빌드·실행.


### Fixed — RTF가 머리말·꼬리말을 양방향으로 잃던 것 (2026-08-07) ⚠ 나가는 RTF 변경

모델은 `PageSetup.Header`/`Footer`/`ShowPageNumbers`를 페이지 설정이 생긴 때부터 들고 있었고
Expand Down Expand Up @@ -690,7 +700,6 @@ JSON/`.flow`도 보존한다. **RTF에만 양쪽이 다 없었고, 그게 결함

`RtfPageChromeTests` 10개. **반증**: 쓰는 쪽만 끄면 5개, 읽는 쪽만 끄면 8개 실패.


### Fixed — 전수조사: 서식이 저장에서 사라지던 5건 (2026-08-07)

**퍼즈가 서식을 만들기만 하고 보지 않았다.** `case 3`/`4`/`12`가 굵기·색·링크·제목·인용·들여쓰기·목록·
Expand Down
70 changes: 51 additions & 19 deletions src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ public partial class RichEditor
private FlowDocument? _internalClipboardDoc;
private string? _internalClipboardText;

// Windows lets one process open the clipboard at a time, and whatever watches it (clipboard history, a
// clipboard manager, a remote-desktop client) opens it right after each change. A call landing in that
// window fails with CLIPBRD_E_CANT_OPEN, and every call site below swallows it and moves on. Measured
// 2026-09-24 on a dev machine: 6% of pastes lost the HTML read (a web page pasted as plain text, or
// nothing when the text read failed too) and 6% of SetContent calls threw (the copy silently lost).
// The window is milliseconds, so the calls retry that one error briefly; any other error is not retried.
private const int ClipboardBusy = unchecked((int)0x800401D0); // CLIPBRD_E_CANT_OPEN
private const int ClipboardAttempts = 10;
private const int ClipboardRetryMs = 20;

// The wait must be an await, never a sleep - for the synchronous SetContent/GetContent too. The watcher
// that holds the clipboard is often reading it: it asks THIS process to render the formats we offered,
// and that request is served on our UI thread. A Thread.Sleep retry blocks that thread, so the watcher
// keeps the clipboard until it gives up and every retry fails (measured: 5 of 300 copies still lost with
// 10 × 20 ms of sleeping; 0 with the same budget awaited).
private static Task RetryWhileClipboardBusyAsync(Action call)
=> RetryWhileClipboardBusyAsync(() => { call(); return Task.FromResult(0); });

private static async Task<T> RetryWhileClipboardBusyAsync<T>(Func<Task<T>> read)
{
for (int attempt = 1; ; attempt++)
{
try { return await read(); }
catch (Exception ex) when (ex.HResult == ClipboardBusy && attempt < ClipboardAttempts)
{
await Task.Delay(ClipboardRetryMs);
}
}
}

/// <summary>Copies the current selection to the clipboard (plain text + HTML). Also copies a
/// block-selected object (table / image / inline table), which has no text selection.</summary>
public Task CopyAsync()
Expand Down Expand Up @@ -46,15 +76,14 @@ public Task CopyAsync()

if (!HasSelection) return Task.CompletedTask;
var range = new TextRange(_selStart, _selEnd);
SetClipboardFromSelection(BuildSelectionDocument(range), range.GetText());
return Task.CompletedTask;
return SetClipboardFromSelection(BuildSelectionDocument(range), range.GetText());
}

// Puts a selection sub-document on the system clipboard as plain text + HTML (CF_HTML) + RTF, and keeps
// the internal rich snapshot for a loss-free in-app paste. RTF matters for tables: HWP (and Word)
// import table structure far more reliably from RTF than from HTML — without it, a copied table pastes
// into HWP as plain text. The internal/HTML/RTF read paths all understand these.
private void SetClipboardFromSelection(FlowDocument selDoc, string plain)
private async Task SetClipboardFromSelection(FlowDocument selDoc, string plain)
{
// Plain text is built with LF between paragraphs; LF-only shows as a single line in many Windows
// consumers (Notepad, native text boxes), so normalize to the platform newline (CRLF) before it
Expand Down Expand Up @@ -87,25 +116,30 @@ private void SetClipboardFromSelection(FlowDocument selDoc, string plain)

_internalClipboardDoc = selDoc.Clone();
_internalClipboardText = plain;
try { Clipboard.SetContent(dp); }
try { await RetryWhileClipboardBusyAsync(() => Clipboard.SetContent(dp)); }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
}

/// <summary>Cuts the current selection (copy + delete). Also cuts a block-selected object.</summary>
public async Task CutAsync()
{
if (IsReadOnly) return;
// Delete BEFORE awaiting the copy. What is copied is captured synchronously when CopyAsync starts, but
// the clipboard write may wait for a busy clipboard (RetryWhileClipboardBusyAsync), and during that wait
// the UI thread handles input - a keystroke could move the selection, and the cut would delete that.
if (HasBlockSelection)
{
await CopyAsync();
var copyObject = CopyAsync();
DeleteSelectedObject(); // pushes its own undo checkpoint + AfterEdit
await copyObject;
return;
}
if (!HasSelection) return;
await CopyAsync();
var copy = CopyAsync();
PushUndo(null);
DeleteSelection();
AfterEdit();
await copy;
}

// Copies a whole block object selected as an object (table, divider, …). Builds a one-block selection
Expand All @@ -115,8 +149,7 @@ private Task CopyBlockToClipboard(Block block)
{
var selDoc = new FlowDocument();
selDoc.Blocks.Add((Block)block.Clone());
SetClipboardFromSelection(selDoc, BlockPlainText(block));
return Task.CompletedTask;
return SetClipboardFromSelection(selDoc, BlockPlainText(block));
}

// Copies a table as what it is. An inline table ("treat as character") goes out as a one-paragraph fragment
Expand All @@ -131,8 +164,7 @@ private Task CopyTableToClipboard(TableBlock tb)
line.Inlines.Add((Inline)it.Clone());
var selDoc = new FlowDocument();
selDoc.Blocks.Add(line);
SetClipboardFromSelection(selDoc, BlockPlainText(tb));
return Task.CompletedTask;
return SetClipboardFromSelection(selDoc, BlockPlainText(tb));
}

// Plain-text projection of a single block: a table becomes TSV (tab between cells, newline between
Expand Down Expand Up @@ -180,13 +212,13 @@ public async Task PasteAsync(bool plainOnly = false)
{
if (IsReadOnly || Document == null || _caret.Paragraph == null) return;
DataPackageView view;
try { view = Clipboard.GetContent(); }
try { view = await RetryWhileClipboardBusyAsync(() => Task.FromResult(Clipboard.GetContent())); }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); return; }

string? clipText = null;
if (view.Contains(StandardDataFormats.Text))
{
try { clipText = await view.GetTextAsync(); }
try { clipText = await RetryWhileClipboardBusyAsync(() => view.GetTextAsync().AsTask()); }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); clipText = null; }
}

Expand All @@ -208,7 +240,7 @@ public async Task PasteAsync(bool plainOnly = false)
{
try
{
string rtf = await view.GetRtfAsync();
string rtf = await RetryWhileClipboardBusyAsync(() => view.GetRtfAsync().AsTask());
if (!string.IsNullOrEmpty(rtf) && RtfDocumentFormatter.LooksLikeRtf(rtf))
{
var parsedRtf = RtfDocumentFormatter.Parse(rtf);
Expand All @@ -225,7 +257,7 @@ public async Task PasteAsync(bool plainOnly = false)
{
try
{
string cfhtml0 = await view.GetHtmlFormatAsync();
string cfhtml0 = await RetryWhileClipboardBusyAsync(() => view.GetHtmlFormatAsync().AsTask());
string frag0 = HtmlFormatHelper.GetStaticFragment(cfhtml0);
if (!string.IsNullOrWhiteSpace(frag0))
{
Expand Down Expand Up @@ -255,7 +287,7 @@ public async Task PasteAsync(bool plainOnly = false)
{
try
{
string cfhtml = await view.GetHtmlFormatAsync();
string cfhtml = await RetryWhileClipboardBusyAsync(() => view.GetHtmlFormatAsync().AsTask());
string fragment = HtmlFormatHelper.GetStaticFragment(cfhtml);
if (!string.IsNullOrWhiteSpace(fragment))
{
Expand Down Expand Up @@ -372,7 +404,7 @@ internal async Task CopyImageToClipboardAsync(byte[]? raw, Microsoft.Graphics.Ca
dp.SetData(ImageMetaFormat, $"{(inline ? 1 : 0)};{width.ToString(inv)};{height.ToString(inv)}");
// Original bytes (base64) only when we have them — avoids the PNG re-encode on in-app paste.
if (raw is { Length: > 0 }) dp.SetData(ImageBytesFormat, Convert.ToBase64String(raw));
Clipboard.SetContent(dp);
await RetryWhileClipboardBusyAsync(() => Clipboard.SetContent(dp));
}
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
}
Expand All @@ -383,7 +415,7 @@ internal async Task CopyImageToClipboardAsync(byte[]? raw, Microsoft.Graphics.Ca
if (!view.Contains(ImageBytesFormat)) return null;
try
{
if (await view.GetDataAsync(ImageBytesFormat) is string b64 && b64.Length > 0)
if (await RetryWhileClipboardBusyAsync(() => view.GetDataAsync(ImageBytesFormat).AsTask()) is string b64 && b64.Length > 0)
return Convert.FromBase64String(b64);
}
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
Expand All @@ -395,7 +427,7 @@ internal async Task CopyImageToClipboardAsync(byte[]? raw, Microsoft.Graphics.Ca
{
if (!view.Contains(ImageMetaFormat)) return null;
string? meta;
try { meta = await view.GetDataAsync(ImageMetaFormat) as string; }
try { meta = await RetryWhileClipboardBusyAsync(() => view.GetDataAsync(ImageMetaFormat).AsTask()) as string; }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); return null; }
if (string.IsNullOrEmpty(meta)) return null;
var parts = meta.Split(';');
Expand All @@ -410,7 +442,7 @@ internal async Task CopyImageToClipboardAsync(byte[]? raw, Microsoft.Graphics.Ca

private static async Task<byte[]?> ReadClipboardBitmapBytesAsync(DataPackageView view)
{
var bmpRef = await view.GetBitmapAsync();
var bmpRef = await RetryWhileClipboardBusyAsync(() => view.GetBitmapAsync().AsTask());
using var stream = await bmpRef.OpenReadAsync();
uint size = (uint)stream.Size;
if (size == 0) return null;
Expand Down
5 changes: 3 additions & 2 deletions src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs
Original file line number Diff line number Diff line change
Expand Up @@ -537,11 +537,12 @@ private void BuildLinkMenu(MenuFlyout menu, string uri)
menu.Items.Add(Mi(Loc("EditLink"), () => _ = EditHyperlinkAsync(), true, RichEditorIcon.EditLink));
menu.Items.Add(Mi(Loc("RemoveLink"), () => SetHyperlink(null), true, RichEditorIcon.RemoveLink));
}
menu.Items.Add(Mi(Loc("CopyLink"), () =>
menu.Items.Add(Mi(Loc("CopyLink"), async () =>
{
var dp = new DataPackage();
dp.SetText(uri);
Clipboard.SetContent(dp);
try { await RetryWhileClipboardBusyAsync(() => Clipboard.SetContent(dp)); }
catch (Exception ex) { RichEditorDiagnostics.Report(ex); }
}, true, RichEditorIcon.CopyLink));
menu.Items.Add(Sep());
menu.Items.Add(Mi(Loc("Copy"), () => _ = CopyAsync(), HasSelection, RichEditorIcon.Copy, "Ctrl+C"));
Expand Down
Loading