From bdc36d77f084bd534223e3b9e44e761ee63a3956 Mon Sep 17 00:00:00 2001 From: centwon Date: Sun, 20 Sep 2026 13:05:57 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=BA=A1=EC=B2=98=EB=A5=BC=20=EC=9E=83?= =?UTF-8?q?=EC=9D=80=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=81=8C=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A4=EC=9D=8C=20=ED=81=B4=EB=A6=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=93=9C=EB=A1=AD=EB=90=98=EB=8D=98=20=EA=B2=83=20?= =?UTF-8?q?(1.3=202=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포인터 파이프라인(1단계)으로 그림 손잡이·개체 끌기·텍스트 끌기까지 제스처로 구동해 보니 **결함 1건이 측정으로 드러났다(포트 전용 — 상류엔 문서 내 텍스트 끌기가 없고 외부 파일 드롭만 있다).** - **캡처 상실이 텍스트 끌기를 정리하지 않았다.** `EndPointerDrags`의 옛 주석은 "텍스트 끌기는 건드리지 않는다 — 끝내면 텍스트가 떨어지는데 캡처 상실은 드롭이 아니므로"라고 **의도**로 적혀 있었다. 그러나 *끝내기*와 *떨어뜨리기*는 다르다: 무장이 남아 다음 hover가 드롭 캐럿을 계속 끌고 다녔고, 사용자의 **다음 클릭**이 낡은 미리보기로 `EndTextDrag`를 돌려 선택 영역을 옮겼다(측정). 개체 끌기와 같은 규칙으로 `CancelTextDrag()`를 추가하고 캡처 상실에서 호출한다 — 취소지 완료가 아니다. - **문서 교체에도 같은 정리를 추가**했다. 이쪽 피해는 작다(교체가 선택을 접어 `PerformTextDrop`이 일찍 돌아온다) — 갓 연 파일 위로 드롭 캐럿이 따라다니고 다음 놓기가 일반 클릭 대신 지연된 클릭 경로를 타는 정도. 그래서 테스트도 문서 변화가 아니라 상태를 단정한다. 테스트 826(+5): 그림 손잡이 제스처(놓기·캡처 상실) · 개체 끌기(놓기는 이동, 캡처 상실은 드롭 없음) · 텍스트 끌기 캡처 상실 · 문서 교체 중 개체/텍스트 끌기. 반증 5종 전부 의도한 테스트만 빨강: 텍스트 끌기 취소 제거 · 개체 끌기 놓기 순서 뒤집기 · 캡처 상실의 그림 크기 조절 완료 제거 · 문서 교체의 개체 끌기 취소 제거 · 문서 교체의 텍스트 끌기 취소 제거. ⚠ **하네스 함정 하나를 밟았다(제품 아님)**: 공유 에디터에 같은 좌표를 밀리초 간격으로 누르면 **다중 클릭**으로 판정돼, 두 번째 테스트의 누르기가 끌기 무장 대신 단어 선택이 된다. 실패 메시지는 제품 결함처럼 보였다. `Hosted`가 `_lastPressTime`/`_clickCount`를 초기화한다. 공개 표면 변화 없음. Co-Authored-By: Claude Opus 5 --- .../Controls/RichEditor.DragText.cs | 14 ++ .../Controls/RichEditor.Input.cs | 12 +- .../ControlPointerSequenceTests.cs | 198 ++++++++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/src/WinUIRichEditor/Controls/RichEditor.DragText.cs b/src/WinUIRichEditor/Controls/RichEditor.DragText.cs index 5fdf041..a8536b2 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.DragText.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.DragText.cs @@ -56,6 +56,20 @@ private void ArmTextDrag(Point docPt, PointerStep s) s.Capture.Capture(); } + // Ends an armed text drag WITHOUT dropping — a lost capture is not a release (the same rule as + // CancelObjectDrag). Left armed, the drag outlived the button: the next plain hover kept moving the drop + // caret, and the next CLICK ran EndTextDrag with the stale preview and MOVED the selection somewhere the + // user never dropped it (measured 2026-09-20 through the pointer pipeline). Ending is not dropping. + private void CancelTextDrag() + { + if (!_dragTextArmed) return; + bool wasActive = _dragTextActive; + _dragTextArmed = false; + _dragTextActive = false; + _dropPreview = null; + if (wasActive) { SetCursorShape(InputSystemCursorShape.IBeam); InvalidateCanvas(); } + } + // Pointer move while armed: past the slop the drag activates and the drop preview follows the pointer. private void DragTextMoved(Point docPt) { diff --git a/src/WinUIRichEditor/Controls/RichEditor.Input.cs b/src/WinUIRichEditor/Controls/RichEditor.Input.cs index ad4fb1c..a3843cb 100644 --- a/src/WinUIRichEditor/Controls/RichEditor.Input.cs +++ b/src/WinUIRichEditor/Controls/RichEditor.Input.cs @@ -175,6 +175,11 @@ private void OnDocumentAssigned() _dragUndoPending = false; CancelObjectDrag(); // the dragged object is the old document's; a release would drop it into the new one CancelTableDraw(); // an armed "draw table" pick too: its first click would insert into the new document + // An armed TEXT drag likewise belongs to the document being replaced. The drop itself cannot reach the + // new document (the swap collapses the selection and PerformTextDrop needs one), but the arming left a + // drop caret following the pointer over a file just opened, and the next release took the deferred + // click path instead of an ordinary one. + CancelTextDrag(); // State that belongs to the document being replaced: an armed format painter would paint the NEW // document's next selection with the OLD one's format, and a pending caret style would land on the // new document's first typed text (upstream's ResetInteractionState drops the latter the same way). @@ -512,8 +517,10 @@ private void OnCanvasPointerWheel(object sender, PointerRoutedEventArgs e) // took the pointer). Nothing else clears a drag, so it outlived the button: the next plain hover went on // resizing the column, row or image under the pointer, or extending the selection. End whatever is live. // A normal release clears its own drag first (see EndColumnResize), so arriving after one is a no-op. - // Text drag & drop is left alone: ending it drops the text, and a lost capture is not a drop. An object - // drag is cancelled for the same reason — cancelled, not finished. + // An object drag and a text drag are CANCELLED, not finished — a lost capture is not a drop. (Leaving + // the text drag alone was once written here as deliberate, on the grounds that ending it would drop the + // text; ending is not dropping, and left armed it made the user's next click perform the drop — + // measured 2026-09-20.) private void OnCanvasPointerCaptureLost(object sender, PointerRoutedEventArgs e) => PointerCaptureLostCore(); /// What a lost capture runs. A test's IPointerCapture.Release calls this the way WinUI @@ -526,6 +533,7 @@ private void EndPointerDrags() if (_resizingRow) FinishRowResize(); if (_resizingImage != null || _resizingInline != null) FinishImageResize(); CancelObjectDrag(); + CancelTextDrag(); if (_tableDrawStart != null) CancelTableDraw(); // abandoned, not inserted: a lost capture is not a release if (_isSelecting) { _isSelecting = false; StopAutoScroll(); } } diff --git a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs index 81ff8be..9a75e45 100644 --- a/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs +++ b/tests/WinUIRichEditor.Tests/ControlPointerSequenceTests.cs @@ -73,6 +73,12 @@ private static void Hosted(Action body) var ed = Shared.Value; UiThread.Run(() => { + // These tests press real points on ONE shared editor, milliseconds apart. Without this, a press + // at (nearly) the same point as the previous test's is a DOUBLE-CLICK — the press selects a word + // instead of arming a drag, and the failure reads as a product defect in whichever test happens + // to run second (it did: "the press inside the selection did not arm the drag"). + T.GetField("_lastPressTime", NP)!.SetValue(ed, DateTime.MinValue); + T.GetField("_clickCount", NP)!.SetValue(ed, 0); try { body(ed); } finally { Call(ed, "CancelTableDraw"); Call(ed, "EndPointerDrags"); ed.IsReadOnly = false; } }); @@ -289,6 +295,198 @@ public void ACaptureLostAfterANormalRelease_ChangesNothing() }); } + // ---- picture handle, object drag, text drag (phase 2) ------------------------------------------- + + private static Rect TableRect(RichEditor ed, TableBlock tb) + { + var rects = (IDictionary)Field(ed, "_tableRects")!; + Assert.True(rects.Contains(tb), "the table recorded no rect — did it draw?"); + return (Rect)rects[tb]!; + } + + // The left border band, where the SizeAll cursor promises a move. + private static Point TableMoveBorder(RichEditor ed, TableBlock tb) + { + var r = TableRect(ed, tb); + return new Point(r.X + 1, r.Y + r.Height / 2); + } + + private static string Shape(RichEditor ed) => string.Join(",", ed.Document!.Blocks.Select(b => b switch + { + TableBlock => "T", + ImageBlock => "I", + Paragraph p => string.Concat(p.Inlines.OfType().Select(r => r.Text)) is { Length: > 0 } s ? s : "∅", + _ => "?", + })); + + private static void Select(RichEditor ed, Paragraph p, int from, int to) + { + T.GetField("_selStart", NP)!.SetValue(ed, new TextPointer(p, from)); + T.GetField("_selEnd", NP)!.SetValue(ed, new TextPointer(p, to)); + T.GetField("_caret", NP)!.SetValue(ed, new TextPointer(p, to)); + } + + [Fact] + public void AnObjectDragGesture_MovesTheTable_AndALostCaptureDropsNothing() + { + Hosted(ed => + { + Load(ed, Para("top"), Table(), Para("mid"), Para("end")); + var tb = ed.Document!.Blocks.OfType().Single(); + var border = TableMoveBorder(ed, tb); + var cap = new FakeCapture(ed); + + // A lost capture mid-drag is not a drop: the table stays where it was. + ed.PointerPressedCore(Step(border, cap)); + Assert.True(cap.Held, "the press on the table border did not take the pointer"); + ed.PointerMovedCore(Step(new Point(border.X, border.Y + 2000), cap)); + cap.Lose(); + Assert.Equal("top,T,mid,end", Shape(ed)); + Assert.Null(Field(ed, "_dragObject")); + + // The same gesture, released: it moves. + Draw(ed); + border = TableMoveBorder(ed, tb); + ed.PointerPressedCore(Step(border, cap)); + ed.PointerMovedCore(Step(new Point(border.X, border.Y + 2000), cap)); + ed.PointerReleasedCore(Step(new Point(border.X, border.Y + 2000), cap)); + Assert.Equal("top,mid,T,end", Shape(ed)); // the drop snaps to the last line it reached + + Assert.False(cap.Held); + }); + } + + // A lost capture must not leave a text drag armed. Left armed, the NEXT click performs the drop the + // user never made: the press does not clear the arming, and the release runs EndTextDrag with the stale + // drop preview — the selection moves on a plain click. (Port-only: upstream has no in-document text + // drag, only external file drop, so there was no precedent to compare against.) + [Fact] + public void LosingTheCaptureMidTextDrag_DoesNotMoveTheTextOnTheNextClick() + { + Hosted(ed => + { + Load(ed, Para("drag this text"), Para("target line")); + var paras = ed.Document!.Blocks.OfType().ToArray(); + Select(ed, paras[0], 0, 14); + var inside = DocPointOf(ed, new TextPointer(paras[0], 6)); // strictly inside the selection + var target = DocPointOf(ed, new TextPointer(paras[1], 6)); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(inside, cap)); + Assert.True((bool)Field(ed, "_dragTextArmed")!, "the press inside the selection did not arm the drag"); + ed.PointerMovedCore(Step(target, cap)); // past the slop: drop preview live + cap.Lose(); + + Assert.False((bool)Field(ed, "_dragTextArmed")!, "the text drag survived the lost capture"); + + // What that costs when it survives: the user's next click drops the text. + string before = ed.GetPlainText(); + ed.PointerPressedCore(Step(target, cap)); + ed.PointerReleasedCore(Step(target, cap)); + Assert.Equal(before, ed.GetPlainText()); + }); + } + + private static ImageBlock Picture(int w, int h) + { + var img = new ImageBlock { Width = w, Height = h }; + img.SetImageData(ControlImageDecodeTests.SolidBmp(w, h, 200, 40, 40), "image/bmp"); + return img; + } + + // The corner handle of a selected block picture, at the rect it was DRAWN at. + private static Point CornerHandle(RichEditor ed, ImageBlock img) + { + var r = ((IEnumerable)Call(ed, "BlockImageHandleRects", img)!).First(); + return new Point(r.Right, r.Bottom); + } + + [Fact] + public void APictureResizeGesture_ResizesIt_AndALostCaptureEndsTheDragKeepingTheSize() + { + Hosted(ed => + { + Load(ed, Para("top"), Picture(120, 80), Para("end")); + // Load round-trips through the serializer, so the picture in the document is a NEW instance — + // the one built above is not in it (that mistake cost a run here). + var img = ed.Document!.Blocks.OfType().Single(); + T.GetField("_selectedBlock", NP)!.SetValue(ed, img); + Draw(ed); + var cap = new FakeCapture(ed); + + // Released normally: the picture keeps the dragged size and nothing stays live. + var grip = CornerHandle(ed, img); + ed.PointerPressedCore(Step(grip, cap)); + Assert.True(cap.Held, "the press on the handle did not take the pointer"); + ed.PointerMovedCore(Step(new Point(grip.X + 60, grip.Y + 40), cap)); + ed.PointerReleasedCore(Step(new Point(grip.X + 60, grip.Y + 40), cap)); + double resized = img.Width; + Assert.True(resized > 120, $"the drag did not resize the picture (width {resized})"); + Assert.Null(Field(ed, "_resizingImage")); + Assert.False(cap.Held); + + // Lost mid-drag: the size the user dragged to is kept (capture-lost FINISHES a resize), and the + // drag ends — the hover that follows must not go on resizing. + Draw(ed); + grip = CornerHandle(ed, img); + ed.PointerPressedCore(Step(grip, cap)); + ed.PointerMovedCore(Step(new Point(grip.X + 30, grip.Y + 20), cap)); + cap.Lose(); + double afterLost = img.Width; + Assert.True(afterLost > resized, "the lost capture threw away the drag"); + Assert.Null(Field(ed, "_resizingImage")); + ed.PointerMovedCore(Step(new Point(grip.X + 400, grip.Y + 300), cap)); + Assert.Equal(afterLost, img.Width, 1); + }); + } + + // A document swapped in mid-drag (a file opened, an undo) belongs to nobody's drag: the armed object is + // in the document that is gone, so the move and the release must leave the new one alone. + [Fact] + public void ReplacingTheDocumentMidDrag_LeavesTheNewOneAlone() + { + Hosted(ed => + { + Load(ed, Para("top"), Table(), Para("end")); + var tb = ed.Document!.Blocks.OfType().Single(); + var border = TableMoveBorder(ed, tb); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(border, cap)); + Load(ed, Para("new file"), Para("second")); // the document the drag was about is gone + Assert.Null(Field(ed, "_dragObject")); + + ed.PointerMovedCore(Step(new Point(border.X, border.Y + 500), cap)); + ed.PointerReleasedCore(Step(new Point(border.X, border.Y + 500), cap)); + + Assert.Equal("new file,second", Shape(ed)); + Assert.False(ed.IsModified, "the new document was modified by a drag from the old one"); + }); + } + + // The same rule for an armed TEXT drag. Weaker consequence than the lost-capture case above — the swap + // collapses the selection, so the drop cannot reach the new document — but the arming left a drop caret + // trailing the pointer over a file just opened, so this asserts the state rather than a changed document. + [Fact] + public void ReplacingTheDocumentMidTextDrag_DisarmsIt() + { + Hosted(ed => + { + Load(ed, Para("drag this text"), Para("target line")); + var paras = ed.Document!.Blocks.OfType().ToArray(); + Select(ed, paras[0], 0, 14); + var cap = new FakeCapture(ed); + + ed.PointerPressedCore(Step(DocPointOf(ed, new TextPointer(paras[0], 6)), cap)); + Assert.True((bool)Field(ed, "_dragTextArmed")!); + + Load(ed, Para("new file")); + + Assert.False((bool)Field(ed, "_dragTextArmed")!, "the text drag survived the document swap"); + Assert.Null(Field(ed, "_dropPreview")); + }); + } + // Control: the press core is the ordinary press too, not just the drag branches — a plain click moves // the caret and takes the pointer, so these tests are running the real path and not a drag-only corner. [Fact]