diff --git a/CHANGELOG.md b/CHANGELOG.md index e8183ce..fe8f6e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`(표·인덱스 지정), @@ -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) 상류가 테스트가 닿지 않던 파일을 감사해 고친 결함들을 이 포트에서 **먼저 측정**했다. 빨강이 된 것만 고쳤고, @@ -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`를 페이지 설정이 생긴 때부터 들고 있었고 @@ -690,7 +700,6 @@ JSON/`.flow`도 보존한다. **RTF에만 양쪽이 다 없었고, 그게 결함 `RtfPageChromeTests` 10개. **반증**: 쓰는 쪽만 끄면 5개, 읽는 쪽만 끄면 8개 실패. - ### Fixed — 전수조사: 서식이 저장에서 사라지던 5건 (2026-08-07) **퍼즈가 서식을 만들기만 하고 보지 않았다.** `case 3`/`4`/`12`가 굵기·색·링크·제목·인용·들여쓰기·목록· diff --git a/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs b/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs index 219e416..f137a7a 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Clipboard.cs @@ -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 RetryWhileClipboardBusyAsync(Func> read) + { + for (int attempt = 1; ; attempt++) + { + try { return await read(); } + catch (Exception ex) when (ex.HResult == ClipboardBusy && attempt < ClipboardAttempts) + { + await Task.Delay(ClipboardRetryMs); + } + } + } + /// 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. public Task CopyAsync() @@ -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 @@ -87,7 +116,7 @@ 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); } } @@ -95,17 +124,22 @@ private void SetClipboardFromSelection(FlowDocument selDoc, string plain) 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 @@ -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 @@ -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 @@ -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; } } @@ -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); @@ -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)) { @@ -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)) { @@ -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); } } @@ -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); } @@ -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(';'); @@ -410,7 +442,7 @@ internal async Task CopyImageToClipboardAsync(byte[]? raw, Microsoft.Graphics.Ca private static async Task 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; diff --git a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs index bc9f93b..4d6a174 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.ContextMenu.cs @@ -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"));