Skip to content

Commit 3f753cd

Browse files
authored
fix(editor): restore shift tap selection with native touch selection menu (#2013)
1 parent ca5cb77 commit 3f753cd

File tree

2 files changed

+112
-40
lines changed

2 files changed

+112
-40
lines changed

src/cm/touchSelectionMenu.js

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EditorSelection } from "@codemirror/state";
12
import selectionMenu from "lib/selectionMenu";
23

34
const TAP_MAX_DELAY = 500;
@@ -126,9 +127,12 @@ class TouchSelectionMenuController {
126127
#view;
127128
#container;
128129
#getActiveFile;
130+
#isShiftSelectionActive;
129131
#stateSyncRaf = 0;
130132
#isScrolling = false;
131133
#isPointerInteracting = false;
134+
#shiftSelectionSession = null;
135+
#pendingShiftSelectionClick = null;
132136
#menuActive = false;
133137
#menuRequested = false;
134138
#enabled = true;
@@ -140,6 +144,8 @@ class TouchSelectionMenuController {
140144
this.#container =
141145
options.container || view.dom.closest(".editor-container") || view.dom;
142146
this.#getActiveFile = options.getActiveFile || (() => null);
147+
this.#isShiftSelectionActive =
148+
options.isShiftSelectionActive || (() => false);
143149
this.$menu = document.createElement("menu");
144150
this.$menu.className = "cursor-menu";
145151
this.#bindEvents();
@@ -170,12 +176,16 @@ class TouchSelectionMenuController {
170176
this.#clearMenuShowTimer();
171177
cancelAnimationFrame(this.#stateSyncRaf);
172178
this.#stateSyncRaf = 0;
179+
this.#shiftSelectionSession = null;
180+
this.#pendingShiftSelectionClick = null;
173181
this.#hideMenu(true);
174182
}
175183

176184
setEnabled(enabled) {
177185
this.#enabled = !!enabled;
178186
if (this.#enabled) return;
187+
this.#shiftSelectionSession = null;
188+
this.#pendingShiftSelectionClick = null;
179189
this.#menuRequested = false;
180190
this.#isPointerInteracting = false;
181191
this.#isScrolling = false;
@@ -246,6 +256,8 @@ class TouchSelectionMenuController {
246256

247257
onSessionChanged() {
248258
if (!this.#enabled) return;
259+
this.#shiftSelectionSession = null;
260+
this.#pendingShiftSelectionClick = null;
249261
this.#menuRequested = false;
250262
this.#isPointerInteracting = false;
251263
this.#isScrolling = false;
@@ -265,19 +277,29 @@ class TouchSelectionMenuController {
265277
#onGlobalPointerDown = (event) => {
266278
const target = event.target;
267279
if (this.$menu.contains(target)) return;
268-
if (this.#isIgnoredPointerTarget(target)) return;
280+
if (this.#isIgnoredPointerTarget(target)) {
281+
this.#shiftSelectionSession = null;
282+
return;
283+
}
269284
if (target instanceof Node && this.#view.dom.contains(target)) {
285+
this.#captureShiftSelection(event);
270286
this.#isPointerInteracting = true;
271287
this.#clearMenuShowTimer();
272288
this.#hideMenu();
273289
return;
274290
}
291+
this.#shiftSelectionSession = null;
275292
this.#isPointerInteracting = false;
276293
this.#menuRequested = false;
277294
this.#hideMenu();
278295
};
279296

280-
#onGlobalPointerUp = () => {
297+
#onGlobalPointerUp = (event) => {
298+
if (event.type === "pointerup") {
299+
this.#commitShiftSelection(event);
300+
} else {
301+
this.#shiftSelectionSession = null;
302+
}
281303
if (!this.#isPointerInteracting) return;
282304
this.#isPointerInteracting = false;
283305
if (!this.#enabled) return;
@@ -291,6 +313,79 @@ class TouchSelectionMenuController {
291313
this.#hideMenu();
292314
};
293315

316+
#captureShiftSelection(event) {
317+
if (!this.#canExtendSelection(event)) {
318+
this.#shiftSelectionSession = null;
319+
return;
320+
}
321+
322+
this.#shiftSelectionSession = {
323+
pointerId: event.pointerId,
324+
anchor: this.#view.state.selection.main.anchor,
325+
x: event.clientX,
326+
y: event.clientY,
327+
};
328+
}
329+
330+
#commitShiftSelection(event) {
331+
const session = this.#shiftSelectionSession;
332+
this.#shiftSelectionSession = null;
333+
if (!session) return;
334+
if (!this.#canExtendSelection(event)) return;
335+
if (event.pointerId !== session.pointerId) return;
336+
if (
337+
Math.hypot(event.clientX - session.x, event.clientY - session.y) >
338+
TAP_MAX_DISTANCE
339+
) {
340+
return;
341+
}
342+
const target = event.target;
343+
if (!(target instanceof Node) || !this.#view.dom.contains(target)) return;
344+
if (this.#isIgnoredPointerTarget(target)) return;
345+
346+
// Rely on pointer coordinates instead of click events so touch selection
347+
// keeps working when the browser/native path owns the actual tap.
348+
const head = this.#view.posAtCoords(
349+
{ x: event.clientX, y: event.clientY },
350+
false,
351+
);
352+
this.#view.dispatch({
353+
selection: EditorSelection.range(session.anchor, head),
354+
userEvent: "select.extend",
355+
});
356+
this.#pendingShiftSelectionClick = {
357+
x: event.clientX,
358+
y: event.clientY,
359+
timeStamp: event.timeStamp,
360+
};
361+
event.preventDefault();
362+
}
363+
364+
#canExtendSelection(event) {
365+
if (!this.#enabled) return false;
366+
if (!(event.isTrusted && event.isPrimary)) return false;
367+
if (typeof event.button === "number" && event.button !== 0) return false;
368+
return !!this.#isShiftSelectionActive(event);
369+
}
370+
371+
consumePendingShiftSelectionClick(event) {
372+
const pending = this.#pendingShiftSelectionClick;
373+
this.#pendingShiftSelectionClick = null;
374+
if (!pending || !this.#enabled) return false;
375+
if (event.timeStamp - pending.timeStamp > TAP_MAX_DELAY) return false;
376+
if (
377+
Math.hypot(event.clientX - pending.x, event.clientY - pending.y) >
378+
TAP_MAX_DISTANCE
379+
) {
380+
return false;
381+
}
382+
const target = event.target;
383+
if (!(target instanceof Node) || !this.#view.dom.contains(target))
384+
return false;
385+
if (this.#isIgnoredPointerTarget(target)) return false;
386+
return true;
387+
}
388+
294389
#shouldShowMenu() {
295390
if (this.#isScrolling || this.#isPointerInteracting || !this.#view.hasFocus)
296391
return false;

src/lib/editorManager.js

Lines changed: 15 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import sidebarApps from "sidebarApps";
22
import { indentUnit } from "@codemirror/language";
33
import { search } from "@codemirror/search";
4-
import {
5-
Compartment,
6-
EditorSelection,
7-
EditorState,
8-
Prec,
9-
StateEffect,
10-
} from "@codemirror/state";
4+
import { Compartment, EditorState, Prec, StateEffect } from "@codemirror/state";
115
import { oneDark } from "@codemirror/theme-one-dark";
126
import {
137
closeHoverTooltips,
@@ -200,37 +194,19 @@ async function EditorManager($header, $body) {
200194
},
201195
);
202196

203-
let shiftClickSelectionExtension;
204-
{
205-
const pointerIdMap = new Map();
206-
shiftClickSelectionExtension = EditorView.domEventHandlers({
207-
pointerup(event, view) {
208-
if (!appSettings.value.shiftClickSelection) return;
209-
if (!(event.isTrusted && event.isPrimary)) return;
210-
if (!event.shiftKey && quickTools.$footer.dataset.shift == null) return;
211-
const { pointerId } = event;
212-
const tid = setTimeout(() => pointerIdMap.delete(pointerId), 1001);
213-
pointerIdMap.set(pointerId, [view.state.selection.main.anchor, tid]);
214-
},
215-
click(event, view) {
216-
const { pointerId } = event;
217-
if (!pointerIdMap.has(pointerId)) return false;
218-
const [anchor, tid] = pointerIdMap.get(pointerId);
219-
clearTimeout(tid);
220-
pointerIdMap.delete(pointerId);
221-
view.dispatch({
222-
selection: EditorSelection.range(
223-
anchor,
224-
view.state.selection.main.anchor,
225-
),
226-
userEvent: "select.extend",
227-
});
228-
event.preventDefault();
229-
return true;
230-
},
231-
});
232-
}
233-
197+
const isShiftSelectionActive = (event) => {
198+
if (!appSettings.value.shiftClickSelection) return false;
199+
return !!event?.shiftKey || quickTools?.$footer?.dataset?.shift != null;
200+
};
201+
const shiftClickSelectionExtension = EditorView.domEventHandlers({
202+
click(event) {
203+
if (!touchSelectionController?.consumePendingShiftSelectionClick(event)) {
204+
return false;
205+
}
206+
event.preventDefault();
207+
return true;
208+
},
209+
});
234210
const touchSelectionUpdateExtension = EditorView.updateListener.of(
235211
(update) => {
236212
if (!touchSelectionController) return;
@@ -852,6 +828,7 @@ async function EditorManager($header, $body) {
852828
touchSelectionController = createTouchSelectionMenu(editor, {
853829
container: $container,
854830
getActiveFile: () => manager?.activeFile || null,
831+
isShiftSelectionActive,
855832
});
856833

857834
// Provide minimal Ace-like API compatibility used by plugins

0 commit comments

Comments
 (0)