Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5c99107
feat(ui): word-wise drag after double-click, line-wise after triple-c…
qwen-code-dev-bot Aug 8, 2026
01404d5
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 8, 2026
99af051
fix(ui): copy single-cell word/line selections on release
qwen-code-dev-bot Aug 8, 2026
24ae25c
fix(ui): apply release cell to word/line drags, keep multi-click chain
qwen-code-dev-bot Aug 8, 2026
797f345
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 8, 2026
a6c516e
refactor(ui): dedupe word/line drag state and span dispatch
qwen-code-dev-bot Aug 8, 2026
7cf9520
refactor(ui): dedupe collapsed-range policy and pin multi-click tests
qwen-code-dev-bot Aug 8, 2026
b5cbd2f
test(ui): pin bare-click highlight and multi-row line-drag copy (#8739)
qwen-code-dev-bot Aug 9, 2026
4fd9868
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 9, 2026
a03e911
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 10, 2026
de9c6a1
fix(ui): restore press-time copy for multi-click selection (#8739)
qwen-code-dev-bot Aug 10, 2026
133e7ff
Merge branch 'feat/word-line-drag-selection' of https://github.com/Qw…
qwen-code-dev-bot Aug 10, 2026
b32dbbe
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
d5999ed
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
aaaae0d
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
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
11 changes: 0 additions & 11 deletions packages/cli/src/ui/selection/selection-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,6 @@ export class SelectionState {
}
}

/** Select a resolved word/line span from a multi-click (not a drag). */
selectSpan(
span: { sx: number; sy: number; ex: number; ey: number },
mode: SelectionMode,
): void {
this.anchor = { x: span.sx, y: span.sy };
this.focus = { x: span.ex, y: span.ey };
this.dragging = false;
this.mode = mode;
}

finish(): void {
this.dragging = false;
}
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/ui/selection/use-text-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,67 @@ describe('TextSelectionController', () => {
expect(copyToClipboard).toHaveBeenLastCalledWith('hello');
});

it('extends a double-click word selection word-wise on drag', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2)); // first click on "foo"
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('move', 10)); // drag to "baz"
handler(makeEvent('left-release', 10));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
});

it('extends a triple-click line selection line-wise on drag', () => {
frame = makeTwoLineFrame('hello', 'world!');
viewportRect = { x: 0, y: 0, width: 6, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
handler(makeEvent('move', 6, 2)); // drag to line 1
handler(makeEvent('left-release', 6, 2));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Test gaps: the backward drag branch (cursorAfter === false) has zero coverage, and the triple-click line-drag test cannot discriminate line-snapping from plain char extension.

Failure scenario: the backward branch (anchor set to the span's end a.ex/a.ey, focus to the current span's start current.sx/current.sy) holds the most distinct logic in the diff; a regression there ships green — dragging a double-clicked 'baz' left to 'foo' would select 'foo' instead of 'foo bar baz'. Mutation-verified: de-wiring both extendSpanDrag call sites to selection.extend leaves this line-drag test green, so line-mode snapping could degrade to per-cell extension and the suite would not catch it.

中文说明

(建议) 测试缺口:向后拖动分支(cursorAfter === false)零覆盖;三击行拖动测试无法区分行吸附与普通字符扩展。

影响:向后分支(锚点取原 span 的终点 a.ex/a.ey、焦点取当前 span 的起点 current.sx/current.sy)是本 diff 中最独特的逻辑;该分支一旦回归,套件依然全绿——把双击选中的 'baz' 向左拖到 'foo' 时只会选中 'foo' 而不是 'foo bar baz'。变异验证:把两处 extendSpanDrag 调用替换为 selection.extend 后,本行拖动测试仍然通过,说明行模式吸附退化为逐格扩展也不会被套件发现。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.7)

nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 5,
ey: 1,
});
expect(copyToClipboard).toHaveBeenCalledWith('hello\nworld!');
});

it('copies a single-character word on a no-drag double-click', () => {
frame = makeFrame('a b');
viewportRect = { x: 0, y: 0, width: 3, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));
handler(makeEvent('left-press', 1)); // double-click -> selects "a"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 0,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('a');
});

it('snaps a wide-character spacer to the leading cell', () => {
frame = makeWideFrame();
const handler = mount();
Expand Down
78 changes: 72 additions & 6 deletions packages/cli/src/ui/selection/use-text-selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export function TextSelectionController(
const baselineFrameRef = useRef<ReadonlyFrame | null>(null);
const baselineViewportRectRef = useRef<ViewportRect | null>(null);
const lastClickRef = useRef<ClickRecord | null>(null);
const spanDragRef = useRef<{
mode: 'word' | 'line';
anchorSpan: { sx: number; sy: number; ex: number; ey: number };
} | null>(null);
const bufferRef = useRef<ScreenBuffer | undefined>(undefined);
const propsRef = useRef(props);
propsRef.current = props;
Expand All @@ -113,6 +117,7 @@ export function TextSelectionController(

const clearSelection = useCallback(() => {
const selection = selectionRef.current;
spanDragRef.current = null;
if (selection.isEmpty) {
return;
}
Expand Down Expand Up @@ -183,6 +188,42 @@ export function TextSelectionController(
[getBuffer, stdout],
);

// Extend an active word/line drag so the range spans from the original
// multi-click span to the word/line boundary at the current point (issue
// #8738). Falls back to a single cell when the cursor is over whitespace.
const extendSpanDrag = useCallback(
(point: { x: number; y: number }) => {
const spanDrag = spanDragRef.current;
if (!spanDrag) return;
const selection = selectionRef.current;
const frame = getBuffer()?.frame ?? null;
const current =
spanDrag.mode === 'word'
? (wordSpanAt(frame, point.x, point.y) ?? {
sx: point.x,
sy: point.y,
ex: point.x,
ey: point.y,
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The whitespace fallback collapses focus to the single whitespace cell, so stopping a word/line drag over a gap puts whitespace inside the copied range.

Failure scenario: probe-verified — double-click 'foo' in 'foo bar baz' and drag to the space at grid x=3: selection becomes 0-3 and the clipboard gets 'foo ' with a trailing space; dragging left onto the space before the anchor word copies ' bar'. A natural gesture (stopping a word-drag on a gap) produces clipboard text the user must strip. The single-cell fallback is documented as deliberate, but the whitespace-in-clipboard harm is concrete.

中文说明

(建议) 空白回退会把焦点坍缩到单个空白单元格,导致词/行拖动停在空隙处时复制文本带上空白。

触发场景(探针已验证):在 'foo bar baz' 中双击 'foo' 并拖到 grid x=3 的空格处——选区变为 0-3,剪贴板得到带尾随空格的 'foo ';向左拖到锚点词前的空格则会复制出带前导空格的 ' bar'。在空隙处停住词拖动是自然手势,却产生需要用户手工清理的剪贴板内容。单格回退在注释中是有意为之,但空白进入复制范围的实际损害是具体的。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.7)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined. The single-cell fallback when the cursor is over whitespace is documented intentional behavior of this feature (see the comment above extendSpanDrag), and it matches how mainstream editors handle word-drag onto a gap — the gap is included in the selection and thus in the copied text. Changing that would be a behavior/scope decision beyond issue #8738. The behavior is now pinned by a new test ('falls back to the cursor cell when a word drag lands on whitespace'), so any future change is a deliberate one rather than a silent regression.

中文说明

已拒绝。光标位于空白单元格时回退到单格是本特性文档中明确记载的有意行为(见 extendSpanDrag 上方的注释),也与主流编辑器按词拖动到空隙处的处理一致——空隙会被纳入选区,因而也会进入复制文本。修改该行为属于超出 issue #8738 范围的行为/范围决策。该行为现已由新测试('falls back to the cursor cell when a word drag lands on whitespace')钉住,未来任何改动都将是有意为之,而不是静默回归。

: (lineSpanAt(frame, point.y) ?? {
sx: point.x,
sy: point.y,
ex: point.x,
ey: point.y,
});
const a = spanDrag.anchorSpan;
const cursorAfter =
point.y > a.ey || (point.y === a.ey && point.x >= a.ex);
selection.anchor = cursorAfter
? { x: a.sx, y: a.sy }
: { x: a.ex, y: a.ey };
selection.focus = cursorAfter
? { x: current.ex, y: current.ey }
: { x: current.sx, y: current.sy };
},
[getBuffer],
);

const handleMouse = useCallback(
(event: MouseEvent) => {
const selection = selectionRef.current;
Expand Down Expand Up @@ -220,19 +261,29 @@ export function TextSelectionController(

if (count >= 2) {
const frame = getBuffer()?.frame ?? null;
const mode = count === 2 ? 'word' : 'line';
const span =
count === 2
? wordSpanAt(frame, point.x, point.y)
: lineSpanAt(frame, point.y);
if (span) {
selection.selectSpan(span, count === 2 ? 'word' : 'line');
// Enter a drag-capable word/line selection so a held double/triple
// click can extend by word/line on move (issue #8738). Copy happens
// on release, matching char drags.
selection.mode = mode;
selection.anchor = { x: span.sx, y: span.sy };
selection.focus = { x: span.ex, y: span.ey };
selection.dragging = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The multi-click press now sets selection.dragging = true (the deleted selectSpan set it false), which exposes the multi-click chain to the move handler's lastClickRef.current = null. Any held-button drift now breaks the triple-click chain or extends a plain double-click's copy range.

Failure scenario: under SGR button tracking a move fires whenever the pointer crosses a cell while the button is held. Probe-verified: press1→release1→press2→move(3,1)→release2→press3→release3 yields a null final selection — a 1-cell drift move between press2 and release2 kills the triple-click (press3 computes count=1, char mode). The same mechanism extends a jittery plain double-click's copy range (e.g. 'foo' → 'foo bar'). The old code ignored moves after a multi-click select, so both were impossible.

中文说明

(建议) 多点点击按下时现在设置 selection.dragging = true(被删除的 selectSpan 设置的是 false),这使多点点击链条暴露在 move 处理器的 lastClickRef.current = null 之下:按住期间任何指针漂移都会破坏三击链条,或扩大普通双击的复制范围。

触发场景:SGR 按钮追踪下,按住按键期间指针每跨过一个单元格就会触发 move。探针已验证:press1→release1→press2→move(3,1)→release2→press3→release3 最终得到空选区——press2 与 release2 之间 1 格的漂移就破坏了三击(press3 计算 count=1,进入字符模式)。同样的机制会让抖动的普通双击复制范围扩大(如 'foo' 变成 'foo bar')。旧代码会忽略多点点击之后的 move,这两种情况都不会发生。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.7)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The multi-click path writes SelectionState's four fields directly, duplicating the existing start(point, mode) + extend(point) API — whose mode parameter has no other production caller.

Failure scenario: two code paths now perform the same state transition differently; a future invariant added to start() (validation, clamping, mode handling) silently never applies to word/line selections. The PR itself is the demonstrated cost: selectSpan was a copy of start()'s semantics that drifted one line (dragging = false → true) and had to be deleted, and the inline copy already carries that modified line.

Suggested change
selection.mode = mode;
selection.anchor = { x: span.sx, y: span.sy };
selection.focus = { x: span.ex, y: span.ey };
selection.dragging = true;
selection.start({ x: span.sx, y: span.sy }, mode);
selection.extend({ x: span.ex, y: span.ey });
中文说明

(建议) 多点点击路径直接写 SelectionState 的四个字段,重复实现了已有的 start(point, mode) + extend(point) API——而该 API 的 mode 参数在生产代码中没有其他调用方。

影响:两条代码路径以不同方式执行同一状态转换;未来加在 start() 上的不变量(校验、钳制、模式处理)将静默地不作用于词/行选择。本 PR 本身就是代价的证明:selectSpan 是 start() 语义的复制品,漂移了一行(dragging = false → true)最终被删除,而内联的复制已经带着这一行改动。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.7)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Word/line copy moved to release is silently dropped whenever the selection is invalidated mid-hold: the frame-subscriber clearSelection() sets dragging = false, after which the release early-returns before copySelection().

Failure scenario: double-click a word (or triple-click a line) in the VP viewport while output is streaming. Press2 selects the word with dragging = true; the next rendered frame with new content runs the subscriber (sameViewportContent differs from baselineFrameRef → clearSelection() → dragging = false). The subsequent left-release hits if (!selection.dragging) { return; } — the release never reaches copySelection(). Probe-verified: firing a frame change between press2 and release yields 0 clipboard calls with the PR code, 1 call with copySelection() restored at press. The old code copied the word at press2, so the copy was guaranteed at selection time. The same drop occurs on any scroll/resize/isActive flip during the hold. (Distinct from the 'release never arrives' case: here the release arrives and is fully processed.)

中文说明

(建议) 词/行复制移到 release 后,若按住期间选区被失效清理(流式终端),复制会被静默丢弃:帧订阅者的 clearSelection() 把 dragging 置为 false,随后的 release 在 copySelection() 之前就提前返回。

触发场景:输出流式滚动时在 VP 视口里双击选中一个词(或三击选中一行)。第 2 次按下以 dragging = true 选中词;下一帧新内容使订阅者触发(sameViewportContent 与 baselineFrameRef 不同 → clearSelection() → dragging = false)。随后的 left-release 命中 if (!selection.dragging) { return; }——永远到不了 copySelection()。探针已验证:在 press2 与 release 之间触发一帧内容变化,PR 代码下剪贴板调用为 0 次,恢复按下时复制后为 1 次。旧代码在 press2 即复制,选区生成时复制即已保证。按住期间的滚动/缩放/isActive 翻转同样会触发此丢弃。(与『release 事件未送达』不同:此处 release 已到达且被完整处理。)

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.7)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined. Copy-on-release is this PR's documented design ("Copy happens on release, matching char drags", commit 99af051dd9), and with it an interrupted word/line drag behaves exactly like an interrupted char drag: the selection is cleared on streamed content/scroll/resize and nothing is copied. That clear-on-change behavior is the established B1 scope for all drags, not a regression introduced for word/line mode specifically. Restoring press-time copy also directly conflicts with this round's rc:3741007226 finding, which pins release-only copy as the intended semantic, and would write the clipboard even when the gesture is never completed. The release-only behavior is now pinned by toHaveBeenCalledTimes(1) assertions. If a maintainer prefers the pre-PR guarantee for multi-clicks interrupted mid-hold, it is a one-line change (copySelection() in the count >= 2 press branch) — flagging for maintainer preference.

中文说明

已拒绝。仅在释放时复制是本 PR 明确记载的设计("Copy happens on release, matching char drags",提交 99af051dd9),在此设计下,被打断的词/行拖动与被打断的字符拖动行为完全一致:流式内容/滚动/缩放时选区被清除,不产生复制。这种内容变化即清除的行为是 B1 范围内所有拖动的既定语义,并非词/行模式特有的新回归。恢复按下时复制还会与本轮 rc:3741007226 意见直接冲突——该意见要求把"仅释放时复制"钉住为预期语义,且按下即复制意味着手势尚未完成时就已写入剪贴板。仅释放时复制的行为现已由 toHaveBeenCalledTimes(1) 断言钉住。如果维护者更希望保留 PR 之前"按住期间被打断的多点点击也保证复制"的语义,只需一行改动(在 count >= 2 按下分支加回 copySelection())——已标注供维护者定夺。

spanDragRef.current = { mode, anchorSpan: span };
dragScrollTopRef.current =
propsRef.current.getScrollState().scrollTop;
recordBaseline();
applyHighlight();
copySelection();
return;
Comment on lines 283 to 286

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined, for the same reasons recorded on rc:3740996254: copy-on-release is this PR's documented design and makes interrupted word/line drags behave exactly like interrupted char drags (cleared, no copy) — the established B1 semantic for all drags. Restoring press-time copy conflicts with this round's rc:3741007226 finding, which pins release-only copy, and would write the clipboard even for gestures that never complete. The A/B-verified difference versus the merge base is real but is the documented, intentional convergence of multi-click and drag semantics; the behavior is now pinned by toHaveBeenCalledTimes(1) assertions. If a maintainer prefers the base-tree guarantee for multi-clicks interrupted mid-hold, it is a one-line change (copySelection() in the count >= 2 press branch) — flagging for maintainer preference.

中文说明

已拒绝,理由与 rc:3740996254 上的记录相同:仅在释放时复制是本 PR 明确记载的设计,它使被打断的词/行拖动与被打断的字符拖动行为完全一致(清除选区、不复制)——这是 B1 范围内所有拖动的既定语义。恢复按下时复制与本轮 rc:3741007226 意见冲突(该意见要求钉住"仅释放时复制"),且会导致从未完成的手势也写入剪贴板。A/B 验证出的与 merge base 的差异确实存在,但那是多点点击与拖动语义有意收敛的、文档中记载的结果;该行为现已由 toHaveBeenCalledTimes(1) 断言钉住。如果维护者更希望保留 base 分支上"按住期间被打断的多点点击也保证复制"的语义,只需一行改动(在 count >= 2 按下分支加回 copySelection())——已标注供维护者定夺。

}
}

spanDragRef.current = null;
selection.start(point);
dragScrollTopRef.current = propsRef.current.getScrollState().scrollTop;
recordBaseline();
Expand All @@ -257,22 +308,36 @@ export function TextSelectionController(
if (!mapped) {
return;
}
selection.extend(clampToViewport(mapped.point, mapped.rect));
if (spanDragRef.current) {
extendSpanDrag(clampToViewport(mapped.point, mapped.rect));
} else {
selection.extend(clampToViewport(mapped.point, mapped.rect));
}
applyHighlight();
return;
}

if (event.name === 'left-release') {
// Word/line click-selects are not drags; leave them intact.
if (!selection.dragging) {
return;
}
const spanDrag = spanDragRef.current;
spanDragRef.current = null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Release-time word/line drag extension is dead code. In the left-release handler, spanDragRef.current is set to null before extendSpanDrag(...) is called, and extendSpanDrag's first statement re-reads the ref (const spanDrag = spanDragRef.current; if (!spanDrag) return;) — so the release cell's coordinates are never applied to a word/line drag.

Failure scenario: double/triple-click a word/line and drag, then release at a cell where the terminal emitted no final move event (SGR button tracking samples motion — fast flicks and coalesced events land here). The selection and copied text stop at the last processed move instead of the release point; with no move at all between the multi-click and the release, the range never extends past the clicked word/line. The char-drag path handles exactly this case and has a dedicated test ('includes the release cell when no move event is emitted'); the word/line path silently lacks the same protection, and the three new tests pass because each emits move at the release position first. Probe-verified at HEAD: release at grid 9 with no move yields ex: 2 / 'foo'; with a move at 6 then release at 9, ex: 6 / 'foo bar'; reordering the null after the call fixes both.

Suggested change
const spanDrag = spanDragRef.current;
spanDragRef.current = null;
const spanDrag = spanDragRef.current;
const mapped = mapEvent(event);
if (mapped) {
if (spanDrag) {
extendSpanDrag(clampToViewport(mapped.point, mapped.rect));
} else {
selection.extend(clampToViewport(mapped.point, mapped.rect));
}
}
spanDragRef.current = null;
中文说明

(严重) release 时的词/行拖动扩展是死代码:left-release 处理器先把 spanDragRef.current 置为 null,再调用 extendSpanDrag(...),而 extendSpanDrag 第一行又去读这个 ref(const spanDrag = spanDragRef.current; if (!spanDrag) return;)——因此释放点的坐标从未被应用到词/行拖动的选区上。

触发场景:双击/三击选中词/行后拖动,若终端在释放位置没有发出最后一个 move 事件(SGR 按钮追踪为采样式上报,快速甩动、事件合并都会命中此情形),选区与复制文本会停在最后一次 move 的位置而不是释放点;若多点点击与释放之间完全没有 move,选区根本不会扩展。字符拖动路径专门处理了该场景并有对应测试('includes the release cell when no move event is emitted'),词/行路径缺失同等保护;三个新测试都在释放前先发了 move,因此全部通过。已在 HEAD 用探针验证:无 move 直接释放到 grid 9 时选区止于 ex: 2(复制 'foo');先 move 到 6 再释放到 9 时止于 ex: 6(复制 'foo bar');把置空语句移到调用之后两处均修复。

— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.7)

const mapped = mapEvent(event);
if (mapped) {
selection.extend(clampToViewport(mapped.point, mapped.rect));
if (spanDrag) {
extendSpanDrag(clampToViewport(mapped.point, mapped.rect));
} else {
selection.extend(clampToViewport(mapped.point, mapped.rect));
}
}
selection.finish();
if (selection.isCollapsed || selection.isEmpty) {
// A collapsed range is a real single-cell span in word/line mode,
// but only a bare click in char mode.
if (
selection.isEmpty ||
(selection.isCollapsed && selection.mode === 'char')
) {
clearSelection();
return;
}
Expand All @@ -288,6 +353,7 @@ export function TextSelectionController(
recordBaseline,
mapEvent,
getBuffer,
extendSpanDrag,
],
);

Expand Down
Loading